Compare commits

..

1 Commits

189 changed files with 1626 additions and 9426 deletions

View File

@ -30,7 +30,6 @@ from fields.raws import FilesContainedField
from libs.helper import TimestampField, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import current_account_with_tenant, login_required
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
@ -336,7 +335,7 @@ class MessageFeedbackApi(Resource):
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.rating = args.rating
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
@ -348,9 +347,9 @@ class MessageFeedbackApi(Resource):
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
rating=rating_value,
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_source="admin",
from_account_id=current_user.id,
)
db.session.add(feedback)

View File

@ -298,7 +298,6 @@ class DatasetDocumentListApi(Resource):
if sort == "hit_count":
sub_query = (
sa.select(DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count"))
.where(DocumentSegment.dataset_id == str(dataset_id))
.group_by(DocumentSegment.document_id)
.subquery()
)

View File

@ -27,7 +27,6 @@ from fields.message_fields import MessageInfiniteScrollPagination, MessageListIt
from libs import helper
from libs.helper import UUIDStrOrEmpty
from libs.login import current_account_with_tenant
from models.enums import FeedbackRating
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.errors.app import MoreLikeThisDisabledError
@ -117,7 +116,7 @@ class MessageFeedbackApi(InstalledAppResource):
app_model=app_model,
message_id=message_id,
user=current_user,
rating=FeedbackRating(payload.rating) if payload.rating else None,
rating=payload.rating,
content=payload.content,
)
except MessageNotExistsError:

View File

@ -15,7 +15,6 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from fields.conversation_fields import ResultResponse
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem
from libs.helper import UUIDStrOrEmpty
from models.enums import FeedbackRating
from models.model import App, AppMode, EndUser
from services.errors.message import (
FirstMessageNotExistsError,
@ -117,7 +116,7 @@ class MessageFeedbackApi(Resource):
app_model=app_model,
message_id=message_id,
user=end_user,
rating=FeedbackRating(payload.rating) if payload.rating else None,
rating=payload.rating,
content=payload.content,
)
except MessageNotExistsError:

View File

@ -52,19 +52,8 @@ def handle_webhook(webhook_id: str):
if error:
return jsonify({"error": "Bad Request", "message": error}), 400
trigger_call_depth = WebhookService.extract_workflow_call_depth(
dict(request.headers),
request_method=request.method,
request_path=request.path,
)
# Process webhook call (send to Celery)
WebhookService.trigger_workflow_execution(
webhook_trigger,
webhook_data,
workflow,
call_depth=trigger_call_depth,
)
WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow)
# Return configured response
response_data, status_code = WebhookService.generate_webhook_response(node_config)

View File

@ -25,7 +25,6 @@ from fields.conversation_fields import ResultResponse
from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, WebMessageListItem
from libs import helper
from libs.helper import uuid_value
from models.enums import FeedbackRating
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.errors.app import MoreLikeThisDisabledError
@ -158,7 +157,7 @@ class MessageFeedbackApi(WebApiResource):
app_model=app_model,
message_id=message_id,
user=end_user,
rating=FeedbackRating(payload.rating) if payload.rating else None,
rating=payload.rating,
content=payload.content,
)
except MessageNotExistsError:

View File

@ -76,7 +76,7 @@ from dify_graph.system_variable import SystemVariable
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models import Account, Conversation, EndUser, Message, MessageFile
from models.enums import CreatorUserRole, MessageFileBelongsTo, MessageStatus
from models.enums import CreatorUserRole, MessageStatus
from models.execution_extra_content import HumanInputContent
from models.workflow import Workflow
@ -939,7 +939,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
type=file["type"],
transfer_method=file["transfer_method"],
url=file["remote_url"],
belongs_to=MessageFileBelongsTo.ASSISTANT,
belongs_to="assistant",
upload_file_id=file["related_id"],
created_by_role=CreatorUserRole.ACCOUNT
if message.invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER}

View File

@ -40,7 +40,7 @@ from dify_graph.model_runtime.entities.message_entities import (
from dify_graph.model_runtime.entities.model_entities import ModelPropertyKey
from dify_graph.model_runtime.errors.invoke import InvokeBadRequestError
from extensions.ext_database import db
from models.enums import CreatorUserRole, MessageFileBelongsTo
from models.enums import CreatorUserRole
from models.model import App, AppMode, Message, MessageAnnotation, MessageFile
if TYPE_CHECKING:
@ -419,7 +419,7 @@ class AppRunner:
message_id=message_id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.TOOL_FILE,
belongs_to=MessageFileBelongsTo.ASSISTANT,
belongs_to="assistant",
url=f"/files/tools/{tool_file.id}",
upload_file_id=tool_file.id,
created_by_role=(

View File

@ -33,7 +33,7 @@ from extensions.ext_redis import get_pubsub_broadcast_channel
from libs.broadcast_channel.channel import Topic
from libs.datetime_utils import naive_utc_now
from models import Account
from models.enums import CreatorUserRole, MessageFileBelongsTo
from models.enums import CreatorUserRole
from models.model import App, AppMode, AppModelConfig, Conversation, EndUser, Message, MessageFile
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.conversation import ConversationNotExistsError
@ -225,7 +225,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
message_id=message.id,
type=file.type,
transfer_method=file.transfer_method,
belongs_to=MessageFileBelongsTo.USER,
belongs_to="user",
url=file.remote_url,
upload_file_id=file.related_id,
created_by_role=(CreatorUserRole.ACCOUNT if account_id else CreatorUserRole.END_USER),

View File

@ -84,7 +84,6 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
call_depth=self.application_generate_entity.call_depth,
root_node_id=self._root_node_id,
)
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
@ -92,7 +91,6 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
workflow=self._workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
call_depth=self.application_generate_entity.call_depth,
)
else:
inputs = self.application_generate_entity.inputs
@ -122,7 +120,6 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
call_depth=self.application_generate_entity.call_depth,
root_node_id=self._root_node_id,
)

View File

@ -102,7 +102,6 @@ class WorkflowBasedAppRunner:
graph_runtime_state: GraphRuntimeState,
user_from: UserFrom,
invoke_from: InvokeFrom,
call_depth: int = 0,
workflow_id: str = "",
tenant_id: str = "",
user_id: str = "",
@ -131,7 +130,7 @@ class WorkflowBasedAppRunner:
user_from=user_from,
invoke_from=invoke_from,
),
call_depth=call_depth,
call_depth=0,
)
# Use the provided graph_runtime_state for consistent state management
@ -157,7 +156,6 @@ class WorkflowBasedAppRunner:
workflow: Workflow,
single_iteration_run: Any | None = None,
single_loop_run: Any | None = None,
call_depth: int = 0,
) -> tuple[Graph, VariablePool, GraphRuntimeState]:
"""
Prepare graph, variable pool, and runtime state for single node execution
@ -191,7 +189,6 @@ class WorkflowBasedAppRunner:
node_id=single_iteration_run.node_id,
user_inputs=dict(single_iteration_run.inputs),
graph_runtime_state=graph_runtime_state,
call_depth=call_depth,
node_type_filter_key="iteration_id",
node_type_label="iteration",
)
@ -201,7 +198,6 @@ class WorkflowBasedAppRunner:
node_id=single_loop_run.node_id,
user_inputs=dict(single_loop_run.inputs),
graph_runtime_state=graph_runtime_state,
call_depth=call_depth,
node_type_filter_key="loop_id",
node_type_label="loop",
)
@ -218,7 +214,6 @@ class WorkflowBasedAppRunner:
node_id: str,
user_inputs: dict[str, Any],
graph_runtime_state: GraphRuntimeState,
call_depth: int,
node_type_filter_key: str, # 'iteration_id' or 'loop_id'
node_type_label: str = "node", # 'iteration' or 'loop' for error messages
) -> tuple[Graph, VariablePool]:
@ -288,7 +283,7 @@ class WorkflowBasedAppRunner:
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
call_depth=call_depth,
call_depth=0,
)
node_factory = DifyNodeFactory(

View File

@ -34,7 +34,6 @@ from core.llm_generator.llm_generator import LLMGenerator
from core.tools.signature import sign_tool_file
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.enums import MessageFileBelongsTo
from models.model import AppMode, Conversation, MessageAnnotation, MessageFile
from services.annotation_service import AppAnnotationService
@ -234,7 +233,7 @@ class MessageCycleManager:
task_id=self._application_generate_entity.task_id,
id=message_file.id,
type=message_file.type,
belongs_to=message_file.belongs_to or MessageFileBelongsTo.USER,
belongs_to=message_file.belongs_to or "user",
url=url,
)

View File

@ -34,7 +34,7 @@ from core.tools.workflow_as_tool.tool import WorkflowTool
from dify_graph.file import FileType
from dify_graph.file.models import FileTransferMethod
from extensions.ext_database import db
from models.enums import CreatorUserRole, MessageFileBelongsTo
from models.enums import CreatorUserRole
from models.model import Message, MessageFile
logger = logging.getLogger(__name__)
@ -352,7 +352,7 @@ class ToolEngine:
message_id=agent_message.id,
type=file_type,
transfer_method=FileTransferMethod.TOOL_FILE,
belongs_to=MessageFileBelongsTo.ASSISTANT,
belongs_to="assistant",
url=message.url,
upload_file_id=tool_file_id,
created_by_role=(

View File

@ -282,7 +282,6 @@ class DifyNodeFactory(NodeFactory):
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
secret_key=dify_config.SECRET_KEY,
)
self._llm_credentials_provider, self._llm_model_factory = build_dify_model_access(self._dify_context.tenant_id)

View File

@ -1,27 +0,0 @@
"""Helpers for workflow recursion depth propagation.
The HTTP request node emits a reserved depth header pair on outbound requests,
and ``services.trigger.webhook_service`` validates that pair when a webhook is
received. The signature binds the propagated depth to the concrete HTTP method
and request path so a depth value captured for one endpoint cannot be replayed
verbatim against another path.
"""
import hashlib
import hmac
def build_workflow_call_depth_signature(*, secret_key: str, method: str, path: str, depth: str) -> str:
"""Build the stable HMAC payload for workflow call-depth propagation.
Args:
secret_key: Shared signing key used by both sender and receiver.
method: Outbound or inbound HTTP method.
path: Request path that the signature is bound to.
depth: Workflow call depth value serialized as a string.
Returns:
Hex-encoded HMAC-SHA256 digest for the method/path/depth tuple.
"""
payload = f"{method.upper()}:{path}:{depth}"
return hmac.new(secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()

View File

@ -2,8 +2,3 @@ SYSTEM_VARIABLE_NODE_ID = "sys"
ENVIRONMENT_VARIABLE_NODE_ID = "env"
CONVERSATION_VARIABLE_NODE_ID = "conversation"
RAG_PIPELINE_VARIABLE_NODE_ID = "rag"
# Reserved for internal workflow-to-workflow HTTP calls. External callers should
# not rely on or set this header.
WORKFLOW_CALL_DEPTH_HEADER = "X-Dify-Workflow-Call-Depth"
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER = "X-Dify-Workflow-Call-Depth-Signature"

View File

@ -12,7 +12,6 @@ def build_http_request_config(
max_text_size: int = 1 * 1024 * 1024,
ssl_verify: bool = True,
ssrf_default_max_retries: int = 3,
secret_key: str = "",
) -> HttpRequestNodeConfig:
return HttpRequestNodeConfig(
max_connect_timeout=max_connect_timeout,
@ -22,7 +21,6 @@ def build_http_request_config(
max_text_size=max_text_size,
ssl_verify=ssl_verify,
ssrf_default_max_retries=ssrf_default_max_retries,
secret_key=secret_key,
)

View File

@ -76,7 +76,6 @@ class HttpRequestNodeConfig:
max_text_size: int
ssl_verify: bool
ssrf_default_max_retries: int
secret_key: str = ""
def default_timeout(self) -> "HttpRequestNodeTimeout":
return HttpRequestNodeTimeout(

View File

@ -1,11 +1,3 @@
"""HTTP request execution helpers for workflow nodes.
Besides normal request assembly, this executor is responsible for propagating
workflow recursion depth across outbound HTTP calls. The reserved call-depth
headers are always regenerated from the current node context so user-supplied
values cannot override or poison the propagation contract.
"""
import base64
import json
import secrets
@ -18,8 +10,6 @@ from urllib.parse import urlencode, urlparse
import httpx
from json_repair import repair_json
from dify_graph.call_depth import build_workflow_call_depth_signature
from dify_graph.constants import WORKFLOW_CALL_DEPTH_HEADER, WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER
from dify_graph.file.enums import FileTransferMethod
from dify_graph.runtime import VariablePool
from dify_graph.variables.segments import ArrayFileSegment, FileSegment
@ -51,8 +41,6 @@ BODY_TYPE_TO_CONTENT_TYPE = {
class Executor:
"""Prepare, execute, and log a workflow HTTP request node invocation."""
method: Literal[
"get",
"head",
@ -89,7 +77,6 @@ class Executor:
timeout: HttpRequestNodeTimeout,
variable_pool: VariablePool,
http_request_config: HttpRequestNodeConfig,
workflow_call_depth: int = 0,
max_retries: int | None = None,
ssl_verify: bool | None = None,
http_client: HttpClientProtocol,
@ -133,7 +120,6 @@ class Executor:
# init template
self.variable_pool = variable_pool
self.node_data = node_data
self.workflow_call_depth = workflow_call_depth
self._initialize()
def _initialize(self):
@ -286,33 +272,8 @@ class Executor:
self.data = form_data
def _assembling_headers(self) -> dict[str, Any]:
"""Assemble outbound headers for the request.
Reserved workflow call-depth headers are removed case-insensitively
before the canonical pair is re-added from ``workflow_call_depth``.
The signature path mirrors Flask request matching, so URLs without an
explicit path are normalized to ``/`` before signing. This keeps
propagation deterministic even if a workflow author manually configured
colliding headers on the node.
"""
authorization = deepcopy(self.auth)
headers = deepcopy(self.headers) or {}
reserved_header_names = {
WORKFLOW_CALL_DEPTH_HEADER.lower(),
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER.lower(),
}
headers = {k: v for k, v in headers.items() if k.lower() not in reserved_header_names}
parsed_url = urlparse(self.url)
signed_path = parsed_url.path or "/"
next_call_depth = str(self.workflow_call_depth + 1)
headers[WORKFLOW_CALL_DEPTH_HEADER] = next_call_depth
headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] = build_workflow_call_depth_signature(
secret_key=self._http_request_config.secret_key,
method=self.method,
path=signed_path,
depth=next_call_depth,
)
if self.auth.type == "api-key":
if self.auth.config is None:
raise AuthorizationConfigError("self.authorization config is required")
@ -427,12 +388,6 @@ class Executor:
return self._validate_and_parse_response(response)
def to_log(self):
"""Render the request in raw HTTP form for node logs.
Internal workflow call-depth headers and authentication headers are
masked so operational logs remain useful without exposing replayable or
credential-bearing values.
"""
url_parts = urlparse(self.url)
path = url_parts.path or "/"
@ -455,12 +410,6 @@ class Executor:
if body.type == "form-data":
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
for k, v in headers.items():
if k.lower() in {
WORKFLOW_CALL_DEPTH_HEADER.lower(),
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER.lower(),
}:
raw += f"{k}: [internal]\r\n"
continue
if self.auth.type == "api-key":
authorization_header = "Authorization"
if self.auth.config and self.auth.config.header:

View File

@ -101,7 +101,6 @@ class HttpRequestNode(Node[HttpRequestNodeData]):
timeout=self._get_request_timeout(self.node_data),
variable_pool=self.graph_runtime_state.variable_pool,
http_request_config=self._http_request_config,
workflow_call_depth=self.workflow_call_depth,
ssl_verify=self.node_data.ssl_verify,
http_client=self._http_client,
file_manager=self._file_manager,

View File

@ -158,13 +158,6 @@ class FeedbackFromSource(StrEnum):
ADMIN = "admin"
class FeedbackRating(StrEnum):
"""MessageFeedback rating"""
LIKE = "like"
DISLIKE = "dislike"
class InvokeFrom(StrEnum):
"""How a conversation/message was invoked"""

View File

@ -36,10 +36,7 @@ from .enums import (
BannerStatus,
ConversationStatus,
CreatorUserRole,
FeedbackFromSource,
FeedbackRating,
MessageChainType,
MessageFileBelongsTo,
MessageStatus,
)
from .provider_ids import GenericProviderID
@ -1168,7 +1165,7 @@ class Conversation(Base):
select(func.count(MessageFeedback.id)).where(
MessageFeedback.conversation_id == self.id,
MessageFeedback.from_source == "user",
MessageFeedback.rating == FeedbackRating.LIKE,
MessageFeedback.rating == "like",
)
)
or 0
@ -1179,7 +1176,7 @@ class Conversation(Base):
select(func.count(MessageFeedback.id)).where(
MessageFeedback.conversation_id == self.id,
MessageFeedback.from_source == "user",
MessageFeedback.rating == FeedbackRating.DISLIKE,
MessageFeedback.rating == "dislike",
)
)
or 0
@ -1194,7 +1191,7 @@ class Conversation(Base):
select(func.count(MessageFeedback.id)).where(
MessageFeedback.conversation_id == self.id,
MessageFeedback.from_source == "admin",
MessageFeedback.rating == FeedbackRating.LIKE,
MessageFeedback.rating == "like",
)
)
or 0
@ -1205,7 +1202,7 @@ class Conversation(Base):
select(func.count(MessageFeedback.id)).where(
MessageFeedback.conversation_id == self.id,
MessageFeedback.from_source == "admin",
MessageFeedback.rating == FeedbackRating.DISLIKE,
MessageFeedback.rating == "dislike",
)
)
or 0
@ -1728,8 +1725,8 @@ class MessageFeedback(TypeBase):
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
rating: Mapped[FeedbackRating] = mapped_column(EnumText(FeedbackRating, length=255), nullable=False)
from_source: Mapped[FeedbackFromSource] = mapped_column(EnumText(FeedbackFromSource, length=255), nullable=False)
rating: Mapped[str] = mapped_column(String(255), nullable=False)
from_source: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
from_end_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
from_account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
@ -1782,9 +1779,7 @@ class MessageFile(TypeBase):
)
created_by_role: Mapped[CreatorUserRole] = mapped_column(EnumText(CreatorUserRole, length=255), nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
belongs_to: Mapped[MessageFileBelongsTo | None] = mapped_column(
EnumText(MessageFileBelongsTo, length=255), nullable=True, default=None
)
belongs_to: Mapped[Literal["user", "assistant"] | None] = mapped_column(String(255), nullable=True, default=None)
url: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
upload_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(

View File

@ -7,7 +7,6 @@ from flask import Response
from sqlalchemy import or_
from extensions.ext_database import db
from models.enums import FeedbackRating
from models.model import Account, App, Conversation, Message, MessageFeedback
@ -101,7 +100,7 @@ class FeedbackService:
"ai_response": message.answer[:500] + "..."
if len(message.answer) > 500
else message.answer, # Truncate long responses
"feedback_rating": "👍" if feedback.rating == FeedbackRating.LIKE else "👎",
"feedback_rating": "👍" if feedback.rating == "like" else "👎",
"feedback_rating_raw": feedback.rating,
"feedback_comment": feedback.content or "",
"feedback_source": feedback.from_source,

View File

@ -16,7 +16,6 @@ from dify_graph.model_runtime.entities.model_entities import ModelType
from extensions.ext_database import db
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, AppModelConfig, EndUser, Message, MessageFeedback
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
from repositories.sqlalchemy_execution_extra_content_repository import (
@ -173,7 +172,7 @@ class MessageService:
app_model: App,
message_id: str,
user: Union[Account, EndUser] | None,
rating: FeedbackRating | None,
rating: str | None,
content: str | None,
):
if not user:
@ -198,7 +197,7 @@ class MessageService:
message_id=message.id,
rating=rating,
content=content,
from_source=(FeedbackFromSource.USER if isinstance(user, EndUser) else FeedbackFromSource.ADMIN),
from_source=("user" if isinstance(user, EndUser) else "admin"),
from_end_user_id=(user.id if isinstance(user, EndUser) else None),
from_account_id=(user.id if isinstance(user, Account) else None),
)

View File

@ -402,7 +402,6 @@ class RagPipelineService:
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
secret_key=dify_config.SECRET_KEY,
)
}
default_config = node_class.get_default_config(filters=filters)
@ -436,7 +435,6 @@ class RagPipelineService:
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
secret_key=dify_config.SECRET_KEY,
)
default_config = node_class.get_default_config(filters=final_filters or None)
if not default_config:

View File

@ -1,4 +1,3 @@
import hmac
import json
import logging
import mimetypes
@ -24,8 +23,6 @@ from core.workflow.nodes.trigger_webhook.entities import (
WebhookData,
WebhookParameter,
)
from dify_graph.call_depth import build_workflow_call_depth_signature
from dify_graph.constants import WORKFLOW_CALL_DEPTH_HEADER, WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER
from dify_graph.entities.graph_config import NodeConfigDict
from dify_graph.file.models import FileTransferMethod
from dify_graph.variables.types import ArrayValidation, SegmentType
@ -60,55 +57,10 @@ class WebhookService:
@staticmethod
def _sanitize_key(key: str) -> str:
"""Normalize external keys (headers/params) to workflow-safe variables."""
if not isinstance(key, str):
return key
return key.replace("-", "_")
@classmethod
def extract_workflow_call_depth(
cls,
headers: Mapping[str, Any],
*,
request_method: str,
request_path: str,
) -> int:
"""Extract the reserved workflow recursion depth header.
The depth header is only trusted when accompanied by a valid HMAC
signature for the current request method/path/depth tuple supplied by the
caller while a request context is available. Header lookup is normalized
case-insensitively so mixed-case spellings still round-trip after headers
are materialized into a plain mapping. Invalid, missing, unsigned, or
negative values are treated as external requests and therefore fall back
to depth 0.
"""
normalized_headers = {str(key).lower(): value for key, value in headers.items()}
raw_value = normalized_headers.get(WORKFLOW_CALL_DEPTH_HEADER.lower())
if raw_value is None:
return 0
raw_signature = normalized_headers.get(WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER.lower())
if raw_signature is None:
return 0
normalized_value = str(raw_value).strip()
# The receiver recomputes the signature from the current request context
# instead of trusting the sender's path or method directly.
expected_signature = build_workflow_call_depth_signature(
secret_key=dify_config.SECRET_KEY,
method=request_method,
path=request_path,
depth=normalized_value,
)
if not hmac.compare_digest(str(raw_signature).strip(), expected_signature):
return 0
try:
call_depth = int(normalized_value)
except (TypeError, ValueError):
return 0
return max(call_depth, 0)
@classmethod
def get_webhook_trigger_and_workflow(
cls, webhook_id: str, is_debug: bool = False
@ -792,12 +744,7 @@ class WebhookService:
@classmethod
def trigger_workflow_execution(
cls,
webhook_trigger: WorkflowWebhookTrigger,
webhook_data: dict[str, Any],
workflow: Workflow,
*,
call_depth: int = 0,
cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow
) -> None:
"""Trigger workflow execution via AsyncWorkflowService.
@ -805,8 +752,6 @@ class WebhookService:
webhook_trigger: The webhook trigger object
webhook_data: Processed webhook data for workflow inputs
workflow: The workflow to execute
call_depth: Validated recursion depth derived earlier from the
incoming request metadata
Raises:
ValueError: If tenant owner is not found
@ -825,7 +770,6 @@ class WebhookService:
root_node_id=webhook_trigger.node_id, # Start from the webhook node
inputs=workflow_inputs,
tenant_id=webhook_trigger.tenant_id,
call_depth=call_depth,
)
end_user = EndUserService.get_or_create_end_user_by_type(

View File

@ -26,14 +26,7 @@ class TriggerMetadata(BaseModel):
class TriggerData(BaseModel):
"""Base trigger data model for async workflow execution.
`call_depth` tracks only the current workflow-to-workflow HTTP recursion
depth. It starts at 0 for external triggers and increments once per nested
webhook-triggered workflow call. For webhook triggers, the value is derived
from the reserved depth headers after `WebhookService.extract_workflow_call_depth`
validates the signature against the inbound request context.
"""
"""Base trigger data model for async workflow execution"""
app_id: str
tenant_id: str
@ -41,7 +34,6 @@ class TriggerData(BaseModel):
root_node_id: str
inputs: Mapping[str, Any]
files: Sequence[Mapping[str, Any]] = Field(default_factory=list)
call_depth: int = 0
trigger_type: AppTriggerType
trigger_from: WorkflowRunTriggeredFrom
trigger_metadata: TriggerMetadata | None = None

View File

@ -638,7 +638,6 @@ class WorkflowService:
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
secret_key=dify_config.SECRET_KEY,
)
}
default_config = node_class.get_default_config(filters=filters)
@ -674,7 +673,6 @@ class WorkflowService:
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
secret_key=dify_config.SECRET_KEY,
)
default_config = node_class.get_default_config(filters=resolved_filters or None)
if not default_config:

View File

@ -164,7 +164,7 @@ def _execute_workflow_common(
args=args,
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
call_depth=trigger_data.call_depth,
call_depth=0,
triggered_from=trigger_data.trigger_from,
root_node_id=trigger_data.root_node_id,
graph_engine_layers=[

View File

@ -14,7 +14,6 @@ from controllers.console.app import wraps
from libs.datetime_utils import naive_utc_now
from models import App, Tenant
from models.account import Account, TenantAccountJoin, TenantAccountRole
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import AppMode, MessageFeedback
from services.feedback_service import FeedbackService
@ -78,8 +77,8 @@ class TestFeedbackExportApi:
app_id=app_id,
conversation_id=conversation_id,
message_id=message_id,
rating=FeedbackRating.LIKE,
from_source=FeedbackFromSource.USER,
rating="like",
from_source="user",
content=None,
from_end_user_id=str(uuid.uuid4()),
from_account_id=None,
@ -91,8 +90,8 @@ class TestFeedbackExportApi:
app_id=app_id,
conversation_id=conversation_id,
message_id=message_id,
rating=FeedbackRating.DISLIKE,
from_source=FeedbackFromSource.ADMIN,
rating="dislike",
from_source="admin",
content="The response was not helpful",
from_end_user_id=None,
from_account_id=str(uuid.uuid4()),
@ -278,8 +277,8 @@ class TestFeedbackExportApi:
# Verify service was called with correct parameters
mock_export_feedbacks.assert_called_once_with(
app_id=mock_app_model.id,
from_source=FeedbackFromSource.USER,
rating=FeedbackRating.DISLIKE,
from_source="user",
rating="dislike",
has_comment=True,
start_date="2024-01-01",
end_date="2024-12-31",

View File

@ -7,7 +7,6 @@ from sqlalchemy.orm import Session
from core.plugin.impl.exc import PluginDaemonClientSideError
from models import Account
from models.enums import MessageFileBelongsTo
from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought
from services.account_service import AccountService, TenantService
from services.agent_service import AgentService
@ -853,7 +852,7 @@ class TestAgentService:
type=FileType.IMAGE,
transfer_method=FileTransferMethod.REMOTE_URL,
url="http://example.com/file1.jpg",
belongs_to=MessageFileBelongsTo.USER,
belongs_to="user",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=message.from_account_id,
)
@ -862,7 +861,7 @@ class TestAgentService:
type=FileType.IMAGE,
transfer_method=FileTransferMethod.REMOTE_URL,
url="http://example.com/file2.png",
belongs_to=MessageFileBelongsTo.USER,
belongs_to="user",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=message.from_account_id,
)

View File

@ -8,7 +8,6 @@ from unittest import mock
import pytest
from extensions.ext_database import db
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, Conversation, Message
from services.feedback_service import FeedbackService
@ -48,8 +47,8 @@ class TestFeedbackService:
app_id=app_id,
conversation_id="test-conversation-id",
message_id="test-message-id",
rating=FeedbackRating.LIKE,
from_source=FeedbackFromSource.USER,
rating="like",
from_source="user",
content="Great answer!",
from_end_user_id="user-123",
from_account_id=None,
@ -62,8 +61,8 @@ class TestFeedbackService:
app_id=app_id,
conversation_id="test-conversation-id",
message_id="test-message-id",
rating=FeedbackRating.DISLIKE,
from_source=FeedbackFromSource.ADMIN,
rating="dislike",
from_source="admin",
content="Could be more detailed",
from_end_user_id=None,
from_account_id="admin-456",
@ -180,8 +179,8 @@ class TestFeedbackService:
# Test with filters
result = FeedbackService.export_feedbacks(
app_id=sample_data["app"].id,
from_source=FeedbackFromSource.ADMIN,
rating=FeedbackRating.DISLIKE,
from_source="admin",
rating="dislike",
has_comment=True,
start_date="2024-01-01",
end_date="2024-12-31",
@ -294,8 +293,8 @@ class TestFeedbackService:
app_id=sample_data["app"].id,
conversation_id="test-conversation-id",
message_id="test-message-id",
rating=FeedbackRating.DISLIKE,
from_source=FeedbackFromSource.USER,
rating="dislike",
from_source="user",
content="回答不够详细,需要更多信息",
from_end_user_id="user-123",
from_account_id=None,

View File

@ -7,7 +7,6 @@ import pytest
from sqlalchemy.orm import Session
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import (
App,
AppAnnotationHitHistory,
@ -173,8 +172,8 @@ class TestAppMessageExportServiceIntegration:
app_id=app.id,
conversation_id=conversation.id,
message_id=first_message.id,
rating=FeedbackRating.LIKE,
from_source=FeedbackFromSource.USER,
rating="like",
from_source="user",
content="first",
from_end_user_id=conversation.from_end_user_id,
)
@ -182,8 +181,8 @@ class TestAppMessageExportServiceIntegration:
app_id=app.id,
conversation_id=conversation.id,
message_id=first_message.id,
rating=FeedbackRating.DISLIKE,
from_source=FeedbackFromSource.USER,
rating="dislike",
from_source="user",
content="second",
from_end_user_id=conversation.from_end_user_id,
)
@ -191,8 +190,8 @@ class TestAppMessageExportServiceIntegration:
app_id=app.id,
conversation_id=conversation.id,
message_id=first_message.id,
rating=FeedbackRating.LIKE,
from_source=FeedbackFromSource.ADMIN,
rating="like",
from_source="admin",
content="should-be-filtered",
from_account_id=str(uuid.uuid4()),
)

View File

@ -4,7 +4,6 @@ import pytest
from faker import Faker
from sqlalchemy.orm import Session
from models.enums import FeedbackRating
from models.model import MessageFeedback
from services.app_service import AppService
from services.errors.message import (
@ -406,7 +405,7 @@ class TestMessageService:
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Create feedback
rating = FeedbackRating.LIKE
rating = "like"
content = fake.text(max_nb_chars=100)
feedback = MessageService.create_feedback(
app_model=app, message_id=message.id, user=account, rating=rating, content=content
@ -436,11 +435,7 @@ class TestMessageService:
# Test creating feedback with no user
with pytest.raises(ValueError, match="user cannot be None"):
MessageService.create_feedback(
app_model=app,
message_id=message.id,
user=None,
rating=FeedbackRating.LIKE,
content=fake.text(max_nb_chars=100),
app_model=app, message_id=message.id, user=None, rating="like", content=fake.text(max_nb_chars=100)
)
def test_create_feedback_update_existing(
@ -457,14 +452,14 @@ class TestMessageService:
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Create initial feedback
initial_rating = FeedbackRating.LIKE
initial_rating = "like"
initial_content = fake.text(max_nb_chars=100)
feedback = MessageService.create_feedback(
app_model=app, message_id=message.id, user=account, rating=initial_rating, content=initial_content
)
# Update feedback
updated_rating = FeedbackRating.DISLIKE
updated_rating = "dislike"
updated_content = fake.text(max_nb_chars=100)
updated_feedback = MessageService.create_feedback(
app_model=app, message_id=message.id, user=account, rating=updated_rating, content=updated_content
@ -492,11 +487,7 @@ class TestMessageService:
# Create initial feedback
feedback = MessageService.create_feedback(
app_model=app,
message_id=message.id,
user=account,
rating=FeedbackRating.LIKE,
content=fake.text(max_nb_chars=100),
app_model=app, message_id=message.id, user=account, rating="like", content=fake.text(max_nb_chars=100)
)
# Delete feedback by setting rating to None
@ -547,7 +538,7 @@ class TestMessageService:
app_model=app,
message_id=message.id,
user=account,
rating=FeedbackRating.LIKE if i % 2 == 0 else FeedbackRating.DISLIKE,
rating="like" if i % 2 == 0 else "dislike",
content=f"Feedback {i}: {fake.text(max_nb_chars=50)}",
)
feedbacks.append(feedback)
@ -577,11 +568,7 @@ class TestMessageService:
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
MessageService.create_feedback(
app_model=app,
message_id=message.id,
user=account,
rating=FeedbackRating.LIKE,
content=f"Feedback {i}",
app_model=app, message_id=message.id, user=account, rating="like", content=f"Feedback {i}"
)
# Get feedbacks with pagination

View File

@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from enums.cloud_plan import CloudPlan
from extensions.ext_redis import redis_client
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import DataSourceType, FeedbackFromSource, FeedbackRating, MessageChainType, MessageFileBelongsTo
from models.enums import DataSourceType, MessageChainType
from models.model import (
App,
AppAnnotationHitHistory,
@ -166,7 +166,7 @@ class TestMessagesCleanServiceIntegration:
name="Test conversation",
inputs={},
status="normal",
from_source=FeedbackFromSource.USER,
from_source="api",
from_end_user_id=str(uuid.uuid4()),
)
db_session_with_containers.add(conversation)
@ -196,7 +196,7 @@ class TestMessagesCleanServiceIntegration:
answer_unit_price=Decimal("0.002"),
total_price=Decimal("0.003"),
currency="USD",
from_source=FeedbackFromSource.USER,
from_source="api",
from_account_id=conversation.from_end_user_id,
created_at=created_at,
)
@ -216,8 +216,8 @@ class TestMessagesCleanServiceIntegration:
app_id=message.app_id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating.LIKE,
from_source=FeedbackFromSource.USER,
rating="like",
from_source="api",
from_end_user_id=str(uuid.uuid4()),
)
db_session_with_containers.add(feedback)
@ -249,7 +249,7 @@ class TestMessagesCleanServiceIntegration:
type="image",
transfer_method="local_file",
url="http://example.com/test.jpg",
belongs_to=MessageFileBelongsTo.USER,
belongs_to="user",
created_by_role="end_user",
created_by=str(uuid.uuid4()),
)

View File

@ -48,42 +48,41 @@ class TestToolTransformService:
name=fake.company(),
description=fake.text(max_nb_chars=100),
icon='{"background": "#FF6B6B", "content": "🔧"}',
icon_dark='{"background": "#252525", "content": "🔧"}',
tenant_id="test_tenant_id",
user_id="test_user_id",
credentials_str='{"auth_type": "api_key_header", "api_key": "test_key"}',
schema="{}",
schema_type_str="openapi",
tools_str="[]",
credentials={"auth_type": "api_key_header", "api_key": "test_key"},
provider_type="api",
)
elif provider_type == "builtin":
provider = BuiltinToolProvider(
name=fake.company(),
description=fake.text(max_nb_chars=100),
icon="🔧",
icon_dark="🔧",
tenant_id="test_tenant_id",
user_id="test_user_id",
provider="test_provider",
credential_type="api_key",
encrypted_credentials='{"api_key": "test_key"}',
credentials={"api_key": "test_key"},
)
elif provider_type == "workflow":
provider = WorkflowToolProvider(
name=fake.company(),
description=fake.text(max_nb_chars=100),
icon='{"background": "#FF6B6B", "content": "🔧"}',
icon_dark='{"background": "#252525", "content": "🔧"}',
tenant_id="test_tenant_id",
user_id="test_user_id",
app_id="test_workflow_id",
label="Test Workflow",
version="1.0.0",
parameter_configuration="[]",
workflow_id="test_workflow_id",
)
elif provider_type == "mcp":
provider = MCPToolProvider(
name=fake.company(),
icon='{"background": "#FF6B6B", "content": "🔧"}',
description=fake.text(max_nb_chars=100),
provider_icon='{"background": "#FF6B6B", "content": "🔧"}',
tenant_id="test_tenant_id",
user_id="test_user_id",
server_url="https://mcp.example.com",
server_url_hash="test_server_url_hash",
server_identifier="test_server",
tools='[{"name": "test_tool", "description": "Test tool"}]',
authed=True,

View File

@ -31,7 +31,6 @@ from controllers.service_api.app.message import (
MessageListQuery,
MessageSuggestedApi,
)
from models.enums import FeedbackRating
from models.model import App, AppMode, EndUser
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import (
@ -311,7 +310,7 @@ class TestMessageService:
app_model=Mock(spec=App),
message_id=str(uuid.uuid4()),
user=Mock(spec=EndUser),
rating=FeedbackRating.LIKE,
rating="like",
content="Great response!",
)
@ -327,7 +326,7 @@ class TestMessageService:
app_model=Mock(spec=App),
message_id="invalid_message_id",
user=Mock(spec=EndUser),
rating=FeedbackRating.LIKE,
rating="like",
content=None,
)

View File

@ -11,7 +11,6 @@ import controllers.trigger.webhook as module
def mock_request():
module.request = types.SimpleNamespace(
method="POST",
path="/triggers/webhook/wh-1",
headers={"x-test": "1"},
args={"a": "b"},
)
@ -57,17 +56,14 @@ class TestHandleWebhook:
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
@patch.object(module.WebhookService, "trigger_workflow_execution")
@patch.object(module.WebhookService, "generate_webhook_response")
@patch.object(module.WebhookService, "extract_workflow_call_depth", return_value=4)
def test_success(
self,
mock_extract_depth,
mock_generate,
mock_trigger,
mock_extract,
mock_get,
):
webhook_trigger = DummyWebhookTrigger()
mock_get.return_value = (webhook_trigger, "workflow", "node_config")
mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config")
mock_extract.return_value = {"input": "x"}
mock_generate.return_value = ({"ok": True}, 200)
@ -75,12 +71,7 @@ class TestHandleWebhook:
assert status == 200
assert response["ok"] is True
mock_extract_depth.assert_called_once_with(
{"x-test": "1"},
request_method="POST",
request_path=module.request.path,
)
mock_trigger.assert_called_once_with(webhook_trigger, {"input": "x"}, "workflow", call_depth=4)
mock_trigger.assert_called_once()
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
@patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad"))

View File

@ -124,7 +124,6 @@ class TestWorkflowBasedAppRunner:
node_id="node-1",
user_inputs={},
graph_runtime_state=graph_runtime_state,
call_depth=3,
node_type_filter_key="iteration_id",
node_type_label="iteration",
)
@ -132,35 +131,6 @@ class TestWorkflowBasedAppRunner:
assert graph is not None
assert variable_pool is graph_runtime_state.variable_pool
def test_init_graph_passes_call_depth_into_node_factory(self, monkeypatch):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable.default()),
start_at=0.0,
)
captured: dict[str, int] = {}
class _FakeNodeFactory:
def __init__(self, *, graph_init_params, graph_runtime_state):
captured["call_depth"] = graph_init_params.call_depth
monkeypatch.setattr("core.app.apps.workflow_app_runner.DifyNodeFactory", _FakeNodeFactory)
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.Graph.init",
lambda **kwargs: SimpleNamespace(),
)
graph = runner._init_graph(
graph_config={"nodes": [{"id": "start", "data": {"type": "start", "version": "1"}}], "edges": []},
graph_runtime_state=runtime_state,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=4,
)
assert graph is not None
assert captured["call_depth"] == 4
def test_handle_graph_run_events_and_pause_notifications(self, monkeypatch):
published: list[object] = []

View File

@ -100,7 +100,6 @@ def test_run_uses_single_node_execution_branch(
workflow=workflow,
single_iteration_run=single_iteration_run,
single_loop_run=single_loop_run,
call_depth=0,
)
init_graph.assert_not_called()
@ -157,7 +156,6 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None:
node_id="loop-node",
user_inputs={},
graph_runtime_state=graph_runtime_state,
call_depth=0,
node_type_filter_key="loop_id",
node_type_label="loop",
)

View File

@ -2,8 +2,6 @@ import pytest
from configs import dify_config
from core.helper.ssrf_proxy import ssrf_proxy
from dify_graph.call_depth import build_workflow_call_depth_signature
from dify_graph.constants import WORKFLOW_CALL_DEPTH_HEADER, WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER
from dify_graph.file.file_manager import file_manager
from dify_graph.nodes.http_request import (
BodyData,
@ -26,9 +24,7 @@ HTTP_REQUEST_CONFIG = HttpRequestNodeConfig(
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
secret_key=dify_config.SECRET_KEY,
)
TEST_SECRET_KEY = "test-secret-key"
def test_executor_with_json_body_and_number_variable():
@ -665,320 +661,3 @@ def test_executor_with_json_body_preserves_numbers_and_strings():
assert executor.json["count"] == 42
assert executor.json["id"] == "abc-123"
def test_executor_propagates_workflow_call_depth_header():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="Depth propagation",
method="get",
url="http://localhost:5001/triggers/webhook/test-webhook",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="X-Test: value",
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key=TEST_SECRET_KEY,
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
headers = executor._assembling_headers()
assert headers["X-Test"] == "value"
assert headers[WORKFLOW_CALL_DEPTH_HEADER] == "3"
assert headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] == build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="get",
path="/triggers/webhook/test-webhook",
depth="3",
)
def test_executor_replaces_lowercase_reserved_headers_for_internal_webhook_target():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="Reserved header replacement",
method="get",
url="http://localhost:5001/triggers/webhook/test-webhook",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers=(
"x-dify-workflow-call-depth: user-value\n"
"x-dify-workflow-call-depth-signature: user-signature\n"
"X-Test: value"
),
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key=TEST_SECRET_KEY,
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
headers = executor._assembling_headers()
assert headers["X-Test"] == "value"
assert headers[WORKFLOW_CALL_DEPTH_HEADER] == "3"
assert headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] == build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="get",
path="/triggers/webhook/test-webhook",
depth="3",
)
assert "x-dify-workflow-call-depth" not in headers
assert "x-dify-workflow-call-depth-signature" not in headers
def test_executor_propagates_workflow_call_depth_with_empty_secret():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="Depth propagation with empty secret",
method="get",
url="http://localhost:5001/triggers/webhook/test-webhook",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="X-Test: value",
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key="",
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
headers = executor._assembling_headers()
assert headers[WORKFLOW_CALL_DEPTH_HEADER] == "3"
assert headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] == build_workflow_call_depth_signature(
secret_key="",
method="get",
path="/triggers/webhook/test-webhook",
depth="3",
)
def test_executor_log_masks_internal_depth_headers():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="Depth propagation",
method="get",
url="http://localhost:5001/triggers/webhook/test-webhook",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="X-Test: value",
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key=TEST_SECRET_KEY,
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
raw_log = executor.to_log()
assert f"{WORKFLOW_CALL_DEPTH_HEADER}: [internal]" in raw_log
assert f"{WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER}: [internal]" in raw_log
assert "X-Dify-Workflow-Call-Depth: 3" not in raw_log
def test_executor_log_masks_reserved_headers_regardless_of_case():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="Reserved header replacement",
method="get",
url="http://localhost:5001/triggers/webhook/test-webhook",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers=(
"x-dify-workflow-call-depth: user-value\n"
"x-dify-workflow-call-depth-signature: user-signature\n"
"X-Test: value"
),
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key=TEST_SECRET_KEY,
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
raw_log = executor.to_log()
assert "x-dify-workflow-call-depth: [internal]" not in raw_log
assert "x-dify-workflow-call-depth-signature: [internal]" not in raw_log
assert f"{WORKFLOW_CALL_DEPTH_HEADER}: [internal]" in raw_log
assert f"{WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER}: [internal]" in raw_log
assert "user-signature" not in raw_log
assert "user-value" not in raw_log
def test_executor_propagates_workflow_call_depth_to_arbitrary_target_with_secret():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="External target",
method="get",
url="https://api.example.com/data",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="X-Test: value",
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key=TEST_SECRET_KEY,
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
headers = executor._assembling_headers()
assert headers["X-Test"] == "value"
assert headers[WORKFLOW_CALL_DEPTH_HEADER] == "3"
assert headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] == build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="get",
path="/data",
depth="3",
)
def test_executor_normalizes_empty_url_path_when_signing_workflow_call_depth():
variable_pool = VariablePool(
system_variables=SystemVariable.default(),
user_inputs={},
)
node_data = HttpRequestNodeData(
title="External target without explicit path",
method="get",
url="https://api.example.com",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="X-Test: value",
params="",
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
http_request_config=HttpRequestNodeConfig(
max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout,
max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout,
max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout,
max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size,
max_text_size=HTTP_REQUEST_CONFIG.max_text_size,
ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify,
ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries,
secret_key=TEST_SECRET_KEY,
),
variable_pool=variable_pool,
workflow_call_depth=2,
http_client=ssrf_proxy,
file_manager=file_manager,
)
headers = executor._assembling_headers()
assert headers["X-Test"] == "value"
assert headers[WORKFLOW_CALL_DEPTH_HEADER] == "3"
assert headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] == build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="get",
path="/",
depth="3",
)

View File

@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.workflow.workflow_entry import WorkflowEntry
from dify_graph.graph_engine.command_channels.redis_channel import RedisChannel
@ -136,27 +134,3 @@ class TestWorkflowEntryRedisChannel:
assert len(events) == 2
assert events[0] == mock_event1
assert events[1] == mock_event2
def test_workflow_entry_rejects_depth_over_limit(self):
mock_graph = MagicMock()
mock_variable_pool = MagicMock(spec=VariablePool)
mock_graph_runtime_state = MagicMock(spec=GraphRuntimeState)
mock_graph_runtime_state.variable_pool = mock_variable_pool
with (
patch("core.workflow.workflow_entry.dify_config.WORKFLOW_CALL_MAX_DEPTH", 1),
pytest.raises(ValueError, match="Max workflow call depth 1 reached."),
):
WorkflowEntry(
tenant_id="test-tenant",
app_id="test-app",
workflow_id="test-workflow",
graph_config={"nodes": [], "edges": []},
graph=mock_graph,
user_id="test-user",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=2,
variable_pool=mock_variable_pool,
graph_runtime_state=mock_graph_runtime_state,
)

View File

@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch
import pytest
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, EndUser, Message
from services.errors.message import (
FirstMessageNotExistsError,
@ -821,14 +820,14 @@ class TestMessageServiceFeedback:
app_model=app,
message_id="msg-123",
user=user,
rating=FeedbackRating.LIKE,
rating="like",
content="Good answer",
)
# Assert
assert result.rating == FeedbackRating.LIKE
assert result.rating == "like"
assert result.content == "Good answer"
assert result.from_source == FeedbackFromSource.USER
assert result.from_source == "user"
mock_db.session.add.assert_called_once()
mock_db.session.commit.assert_called_once()
@ -853,13 +852,13 @@ class TestMessageServiceFeedback:
app_model=app,
message_id="msg-123",
user=user,
rating=FeedbackRating.DISLIKE,
rating="dislike",
content="Bad answer",
)
# Assert
assert result == feedback
assert feedback.rating == FeedbackRating.DISLIKE
assert feedback.rating == "dislike"
assert feedback.content == "Bad answer"
mock_db.session.commit.assert_called_once()

View File

@ -5,13 +5,8 @@ import pytest
from flask import Flask
from werkzeug.datastructures import FileStorage
from configs import dify_config
from dify_graph.call_depth import build_workflow_call_depth_signature
from dify_graph.constants import WORKFLOW_CALL_DEPTH_HEADER, WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER
from services.trigger.webhook_service import WebhookService
TEST_SECRET_KEY = "test-secret-key"
class TestWebhookServiceUnit:
"""Unit tests for WebhookService focusing on business logic without database dependencies."""
@ -564,266 +559,3 @@ class TestWebhookServiceUnit:
result = _prepare_webhook_execution("test_webhook", is_debug=True)
assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None)
def test_extract_workflow_call_depth_defaults_to_zero_for_invalid_values(self):
assert WebhookService.extract_workflow_call_depth({}, request_method="POST", request_path="/webhook") == 0
assert (
WebhookService.extract_workflow_call_depth(
{WORKFLOW_CALL_DEPTH_HEADER: "abc"},
request_method="POST",
request_path="/webhook",
)
== 0
)
assert (
WebhookService.extract_workflow_call_depth(
{WORKFLOW_CALL_DEPTH_HEADER.lower(): "-1"},
request_method="POST",
request_path="/webhook",
)
== 0
)
def test_extract_workflow_call_depth_ignores_unsigned_external_header(self):
assert (
WebhookService.extract_workflow_call_depth(
{WORKFLOW_CALL_DEPTH_HEADER: "5"},
request_method="POST",
request_path="/webhook",
)
== 0
)
def test_extract_workflow_call_depth_honors_signed_internal_header(self):
with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY):
signature = build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="POST",
path="/triggers/webhook/test-webhook",
depth="4",
)
assert (
WebhookService.extract_workflow_call_depth(
{
WORKFLOW_CALL_DEPTH_HEADER: "4",
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature,
},
request_method="POST",
request_path="/triggers/webhook/test-webhook",
)
== 4
)
def test_extract_workflow_call_depth_accepts_mixed_case_reserved_headers(self):
with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY):
signature = build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="POST",
path="/triggers/webhook/test-webhook",
depth="4",
)
assert (
WebhookService.extract_workflow_call_depth(
{
"X-Dify-Workflow-Call-Depth": "4",
"X-Dify-Workflow-Call-Depth-Signature": signature,
},
request_method="POST",
request_path="/triggers/webhook/test-webhook",
)
== 4
)
def test_extract_workflow_call_depth_rejects_signature_for_other_path(self):
with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY):
wrong_signature = build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="POST",
path="/triggers/webhook/wrong-webhook",
depth="4",
)
assert (
WebhookService.extract_workflow_call_depth(
{
WORKFLOW_CALL_DEPTH_HEADER: "4",
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: wrong_signature,
},
request_method="POST",
request_path="/triggers/webhook/right-webhook",
)
== 0
)
@patch("services.trigger.webhook_service.dify_config")
def test_extract_workflow_call_depth_honors_signature_with_empty_secret(self, mock_config):
mock_config.SECRET_KEY = ""
signature = build_workflow_call_depth_signature(
secret_key="",
method="POST",
path="/triggers/webhook/test-webhook",
depth="4",
)
assert (
WebhookService.extract_workflow_call_depth(
{
WORKFLOW_CALL_DEPTH_HEADER: "4",
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature,
},
request_method="POST",
request_path="/triggers/webhook/test-webhook",
)
== 4
)
@patch("services.trigger.webhook_service.QuotaType")
@patch("services.trigger.webhook_service.EndUserService")
@patch("services.trigger.webhook_service.AsyncWorkflowService")
@patch("services.trigger.webhook_service.Session")
@patch("services.trigger.webhook_service.db")
def test_trigger_workflow_execution_preserves_header_depth(
self,
mock_db,
mock_session,
mock_async_workflow_service,
mock_end_user_service,
mock_quota_type,
):
webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook")
workflow = MagicMock(id="workflow")
mock_end_user = MagicMock()
mock_end_user_service.get_or_create_end_user_by_type.return_value = mock_end_user
mock_db.engine = MagicMock()
mock_session.return_value.__enter__.return_value = MagicMock()
signature = build_workflow_call_depth_signature(
secret_key=TEST_SECRET_KEY,
method="POST",
path="/triggers/webhook/test-webhook",
depth="4",
)
with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY):
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}},
workflow,
call_depth=WebhookService.extract_workflow_call_depth(
{
WORKFLOW_CALL_DEPTH_HEADER: "4",
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature,
},
request_method="POST",
request_path="/triggers/webhook/test-webhook",
),
)
trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2]
assert trigger_data.call_depth == 4
@patch("services.trigger.webhook_service.QuotaType")
@patch("services.trigger.webhook_service.EndUserService")
@patch("services.trigger.webhook_service.AsyncWorkflowService")
@patch("services.trigger.webhook_service.Session")
@patch("services.trigger.webhook_service.db")
def test_trigger_workflow_execution_ignores_spoofed_external_depth(
self,
mock_db,
mock_session,
mock_async_workflow_service,
mock_end_user_service,
mock_quota_type,
):
webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook")
workflow = MagicMock(id="workflow")
mock_end_user_service.get_or_create_end_user_by_type.return_value = MagicMock()
mock_db.engine = MagicMock()
mock_session.return_value.__enter__.return_value = MagicMock()
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}},
workflow,
call_depth=WebhookService.extract_workflow_call_depth(
{WORKFLOW_CALL_DEPTH_HEADER: "5"},
request_method="POST",
request_path="/triggers/webhook/test-webhook",
),
)
trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2]
assert trigger_data.call_depth == 0
@patch("services.trigger.webhook_service.QuotaType")
@patch("services.trigger.webhook_service.EndUserService")
@patch("services.trigger.webhook_service.AsyncWorkflowService")
@patch("services.trigger.webhook_service.Session")
@patch("services.trigger.webhook_service.db")
def test_trigger_workflow_execution_rejects_signature_captured_from_non_webhook_request(
self,
mock_db,
mock_session,
mock_async_workflow_service,
mock_end_user_service,
mock_quota_type,
):
webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook")
workflow = MagicMock(id="workflow")
mock_end_user_service.get_or_create_end_user_by_type.return_value = MagicMock()
mock_db.engine = MagicMock()
mock_session.return_value.__enter__.return_value = MagicMock()
captured_signature = build_workflow_call_depth_signature(
secret_key=dify_config.SECRET_KEY,
method="GET",
path="/v1/external-endpoint",
depth="5",
)
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}},
workflow,
call_depth=WebhookService.extract_workflow_call_depth(
{
WORKFLOW_CALL_DEPTH_HEADER: "5",
WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: captured_signature,
},
request_method="POST",
request_path="/triggers/webhook/test-webhook",
),
)
trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2]
assert trigger_data.call_depth == 0
@patch("services.trigger.webhook_service.QuotaType")
@patch("services.trigger.webhook_service.EndUserService")
@patch("services.trigger.webhook_service.AsyncWorkflowService")
@patch("services.trigger.webhook_service.Session")
@patch("services.trigger.webhook_service.db")
def test_trigger_workflow_execution_does_not_require_request_context_when_call_depth_is_passed(
self,
mock_db,
mock_session,
mock_async_workflow_service,
mock_end_user_service,
mock_quota_type,
):
webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook")
workflow = MagicMock(id="workflow")
mock_end_user_service.get_or_create_end_user_by_type.return_value = MagicMock()
mock_db.engine = MagicMock()
mock_session.return_value.__enter__.return_value = MagicMock()
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}},
workflow,
call_depth=4,
)
trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2]
assert trigger_data.call_depth == 4

View File

@ -1,5 +1,3 @@
from unittest.mock import MagicMock, patch
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
from services.workflow.entities import WebhookTriggerData
from tasks import async_workflow_tasks
@ -18,41 +16,3 @@ def test_build_generator_args_sets_skip_flag_for_webhook():
assert args[SKIP_PREPARE_USER_INPUTS_KEY] is True
assert args["inputs"]["webhook_data"]["body"]["foo"] == "bar"
def test_execute_workflow_common_uses_trigger_call_depth():
trigger_data = WebhookTriggerData(
app_id="app",
tenant_id="tenant",
workflow_id="workflow",
root_node_id="node",
inputs={"webhook_data": {"body": {}}},
call_depth=3,
)
trigger_log = MagicMock(
id="log-id",
app_id="app",
workflow_id="workflow",
trigger_data=trigger_data.model_dump_json(),
)
trigger_log_repo = MagicMock()
trigger_log_repo.get_by_id.return_value = trigger_log
session = MagicMock()
session.scalar.side_effect = [MagicMock(), MagicMock()]
session_context = MagicMock()
session_context.__enter__.return_value = session
workflow_generator = MagicMock()
with (
patch.object(async_workflow_tasks.session_factory, "create_session", return_value=session_context),
patch.object(async_workflow_tasks, "SQLAlchemyWorkflowTriggerLogRepository", return_value=trigger_log_repo),
patch.object(async_workflow_tasks, "_get_user", return_value=MagicMock()),
patch.object(async_workflow_tasks, "WorkflowAppGenerator", return_value=workflow_generator),
):
async_workflow_tasks._execute_workflow_common(
async_workflow_tasks.WorkflowTaskData(workflow_trigger_log_id="log-id"),
MagicMock(),
MagicMock(),
)
assert workflow_generator.generate.call_args.kwargs["call_depth"] == 3

View File

@ -4,7 +4,6 @@ import {
ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
@ -20,7 +19,7 @@ const renderScrollArea = (options: {
horizontalThumbClassName?: string
} = {}) => {
return render(
<ScrollAreaRoot className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root">
<ScrollArea className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root">
<ScrollAreaViewport data-testid="scroll-area-viewport" className={options.viewportClassName}>
<ScrollAreaContent data-testid="scroll-area-content">
<div className="h-48 w-48">Scrollable content</div>
@ -44,7 +43,7 @@ const renderScrollArea = (options: {
className={options.horizontalThumbClassName}
/>
</ScrollAreaScrollbar>
</ScrollAreaRoot>,
</ScrollArea>,
)
}
@ -63,38 +62,6 @@ describe('scroll-area wrapper', () => {
expect(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument()
})
})
it('should render the convenience wrapper and apply slot props', async () => {
render(
<>
<p id="installed-apps-label">Installed apps</p>
<ScrollArea
className="h-40 w-40"
slotClassNames={{
content: 'custom-content-class',
scrollbar: 'custom-scrollbar-class',
viewport: 'custom-viewport-class',
}}
labelledBy="installed-apps-label"
data-testid="scroll-area-wrapper-root"
>
<div className="h-48 w-20">Scrollable content</div>
</ScrollArea>
</>,
)
await waitFor(() => {
const root = screen.getByTestId('scroll-area-wrapper-root')
const viewport = screen.getByRole('region', { name: 'Installed apps' })
const content = screen.getByText('Scrollable content').parentElement
expect(root).toBeInTheDocument()
expect(viewport).toHaveClass('custom-viewport-class')
expect(viewport).toHaveAccessibleName('Installed apps')
expect(content).toHaveClass('custom-content-class')
expect(screen.getByText('Scrollable content')).toBeInTheDocument()
})
})
})
describe('Scrollbar', () => {
@ -252,7 +219,7 @@ describe('scroll-area wrapper', () => {
try {
render(
<ScrollAreaRoot className="h-40 w-40" data-testid="scroll-area-root">
<ScrollArea className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaViewport data-testid="scroll-area-viewport">
<ScrollAreaContent data-testid="scroll-area-content">
<div className="h-48 w-48">Scrollable content</div>
@ -269,7 +236,7 @@ describe('scroll-area wrapper', () => {
<ScrollAreaThumb data-testid="scroll-area-horizontal-thumb" />
</ScrollAreaScrollbar>
<ScrollAreaCorner data-testid="scroll-area-corner" />
</ScrollAreaRoot>,
</ScrollArea>,
)
await waitFor(() => {

View File

@ -4,9 +4,9 @@ import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
@ -14,7 +14,7 @@ import {
const meta = {
title: 'Base/Layout/ScrollArea',
component: ScrollAreaRoot,
component: ScrollArea,
parameters: {
layout: 'padded',
docs: {
@ -24,7 +24,7 @@ const meta = {
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ScrollAreaRoot>
} satisfies Meta<typeof ScrollArea>
export default meta
type Story = StoryObj<typeof meta>
@ -135,7 +135,7 @@ const StoryCard = ({
const VerticalPanelPane = () => (
<div className={cn(panelClassName, 'h-[360px]')}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="space-y-3 p-4 pr-6">
<div className="space-y-1">
@ -161,13 +161,13 @@ const VerticalPanelPane = () => (
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
)
const StickyListPane = () => (
<div className={cn(panelClassName, 'h-[360px]')}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaViewport className={cn(insetViewportClassName, '[mask-image:linear-gradient(to_bottom,transparent_0px,black_10px,black_calc(100%-14px),transparent_100%)]')}>
<ScrollAreaContent className="min-h-full">
<div className="sticky top-0 z-10 border-b border-divider-subtle bg-components-panel-bg px-4 pb-3 pt-4">
@ -200,7 +200,7 @@ const StickyListPane = () => (
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
)
@ -216,7 +216,7 @@ const WorkbenchPane = ({
className?: string
}) => (
<div className={cn(panelClassName, 'min-h-0', className)}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="space-y-3 p-4 pr-6">
<div className="space-y-1">
@ -229,13 +229,13 @@ const WorkbenchPane = ({
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
)
const HorizontalRailPane = () => (
<div className={cn(panelClassName, 'h-[272px] min-w-0 max-w-full')}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="min-h-full min-w-max space-y-4 p-4 pb-6">
<div className="space-y-1">
@ -262,7 +262,7 @@ const HorizontalRailPane = () => (
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
)
@ -319,7 +319,7 @@ const ScrollbarStatePane = ({
<p className="text-text-secondary system-sm-regular">{description}</p>
</div>
<div className="mt-4 min-w-0 rounded-[24px] border border-divider-subtle bg-components-panel-bg p-3">
<ScrollAreaRoot className="h-[320px] p-1">
<ScrollArea className="h-[320px] p-1">
<ScrollAreaViewport id={viewportId} className="rounded-[20px] bg-components-panel-bg">
<ScrollAreaContent className="min-w-0 space-y-2 p-4 pr-6">
{scrollbarShowcaseRows.map(item => (
@ -333,7 +333,7 @@ const ScrollbarStatePane = ({
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
</div>
)
@ -347,7 +347,7 @@ const HorizontalScrollbarShowcasePane = () => (
<p className="text-text-secondary system-sm-regular">Current design delivery defines the horizontal scrollbar body, but not a horizontal edge hint.</p>
</div>
<div className="mt-4 min-w-0 rounded-[24px] border border-divider-subtle bg-components-panel-bg p-3">
<ScrollAreaRoot className="h-[240px] p-1">
<ScrollArea className="h-[240px] p-1">
<ScrollAreaViewport className="rounded-[20px] bg-components-panel-bg">
<ScrollAreaContent className="min-h-full min-w-max space-y-4 p-4 pb-6">
<div className="space-y-1">
@ -367,7 +367,7 @@ const HorizontalScrollbarShowcasePane = () => (
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
</div>
)
@ -375,7 +375,7 @@ const HorizontalScrollbarShowcasePane = () => (
const OverlayPane = () => (
<div className="flex h-[420px] min-w-0 items-center justify-center rounded-[28px] bg-[radial-gradient(circle_at_top,_rgba(21,90,239,0.12),_transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.03),transparent)] p-6">
<div className={cn(blurPanelClassName, 'w-full max-w-[360px]')}>
<ScrollAreaRoot className="h-[320px] p-1">
<ScrollArea className="h-[320px] p-1">
<ScrollAreaViewport className="overscroll-contain rounded-[20px] bg-components-panel-bg-blur">
<ScrollAreaContent className="space-y-2 p-3 pr-6">
<div className="sticky top-0 z-10 rounded-xl border border-divider-subtle bg-components-panel-bg-blur px-3 py-3 backdrop-blur-[6px]">
@ -400,14 +400,14 @@ const OverlayPane = () => (
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb className="rounded-full bg-state-base-handle hover:bg-state-base-handle-hover" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
</div>
)
const CornerPane = () => (
<div className={cn(panelClassName, 'h-[320px] w-full max-w-[440px]')}>
<ScrollAreaRoot className={cn(insetScrollAreaClassName, 'overflow-hidden')}>
<ScrollArea className={cn(insetScrollAreaClassName, 'overflow-hidden')}>
<ScrollAreaViewport className={cn(insetViewportClassName, 'bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]')}>
<ScrollAreaContent className="min-h-[420px] min-w-[620px] space-y-4 p-4">
<div className="flex items-start justify-between gap-6">
@ -443,7 +443,7 @@ const CornerPane = () => (
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaCorner className="bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]" />
</ScrollAreaRoot>
</ScrollArea>
</div>
)
@ -475,7 +475,7 @@ const ExploreSidebarWebAppsPane = () => {
</div>
<div className="h-[304px]">
<ScrollAreaRoot className={sidebarScrollAreaClassName}>
<ScrollArea className={sidebarScrollAreaClassName}>
<ScrollAreaViewport className={sidebarViewportClassName}>
<ScrollAreaContent className={sidebarContentClassName}>
{webAppsRows.map((item, index) => (
@ -519,7 +519,7 @@ const ExploreSidebarWebAppsPane = () => {
<ScrollAreaScrollbar className={sidebarScrollbarClassName}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</ScrollArea>
</div>
</div>
</div>
@ -654,7 +654,7 @@ export const PrimitiveComposition: Story = {
description="A stripped-down example for teams that want to start from the base API and add their own shell classes around it. The outer shell adds inset padding so the tracks sit inside the rounded surface instead of colliding with the panel corners."
>
<div className={cn(panelClassName, 'h-[260px] max-w-[420px]')}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="min-w-[560px] space-y-3 p-4 pr-6">
{Array.from({ length: 8 }, (_, index) => (
@ -673,7 +673,7 @@ export const PrimitiveComposition: Story = {
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
</ScrollArea>
</div>
</StoryCard>
),

View File

@ -5,26 +5,12 @@ import * as React from 'react'
import { cn } from '@/utils/classnames'
import styles from './index.module.css'
export const ScrollAreaRoot = BaseScrollArea.Root
export const ScrollArea = BaseScrollArea.Root
export type ScrollAreaRootProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Root>
export const ScrollAreaContent = BaseScrollArea.Content
export type ScrollAreaContentProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Content>
export type ScrollAreaSlotClassNames = {
viewport?: string
content?: string
scrollbar?: string
}
export type ScrollAreaProps = Omit<ScrollAreaRootProps, 'children'> & {
children: React.ReactNode
orientation?: 'vertical' | 'horizontal'
slotClassNames?: ScrollAreaSlotClassNames
label?: string
labelledBy?: string
}
export const scrollAreaScrollbarClassName = cn(
styles.scrollbar,
'flex touch-none select-none overflow-clip p-1 opacity-100 transition-opacity motion-reduce:transition-none',
@ -102,31 +88,3 @@ export function ScrollAreaCorner({
/>
)
}
export function ScrollArea({
children,
className,
orientation = 'vertical',
slotClassNames,
label,
labelledBy,
...props
}: ScrollAreaProps) {
return (
<ScrollAreaRoot className={className} {...props}>
<ScrollAreaViewport
aria-label={label}
aria-labelledby={labelledBy}
className={slotClassNames?.viewport}
role={label || labelledBy ? 'region' : undefined}
>
<ScrollAreaContent className={slotClassNames?.content}>
{children}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation={orientation} className={slotClassNames?.scrollbar}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)
}

View File

@ -93,13 +93,6 @@ describe('SideBar', () => {
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
})
it('should expose an accessible name for the discovery link when the text is hidden', () => {
mockMediaType = MediaType.mobile
renderSideBar()
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
})
it('should render workspace items when installed apps exist', () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
@ -143,15 +136,6 @@ describe('SideBar', () => {
const dividers = container.querySelectorAll('[class*="divider"], hr')
expect(dividers.length).toBeGreaterThan(0)
})
it('should render a button for toggling the sidebar and update its accessible name', () => {
renderSideBar()
const toggleButton = screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' })
fireEvent.click(toggleButton)
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
})
})
describe('User Interactions', () => {

View File

@ -13,7 +13,13 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { ScrollArea } from '@/app/components/base/ui/scroll-area'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/app/components/base/ui/scroll-area'
import { toast } from '@/app/components/base/ui/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
@ -24,9 +30,11 @@ import Item from './app-nav-item'
import NoApps from './no-apps'
const expandedSidebarScrollAreaClassNames = {
root: 'h-full',
viewport: 'overscroll-contain',
content: 'space-y-0.5',
scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]',
viewport: 'overscroll-contain',
thumb: 'rounded-full',
} as const
const SideBar = () => {
@ -96,11 +104,10 @@ const SideBar = () => {
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
<Link
href="/explore/apps"
aria-label={isMobile || isFold ? t('sidebar.title', { ns: 'explore' }) : undefined}
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
<span aria-hidden="true" className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
<span className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
</div>
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-regular')}>{t('sidebar.title', { ns: 'explore' })}</div>}
</Link>
@ -119,12 +126,19 @@ const SideBar = () => {
{shouldUseExpandedScrollArea
? (
<div className="min-h-0 flex-1">
<ScrollArea
className="h-full"
slotClassNames={expandedSidebarScrollAreaClassNames}
labelledBy={webAppsLabelId}
>
{installedAppItems}
<ScrollArea className={expandedSidebarScrollAreaClassNames.root}>
<ScrollAreaViewport
aria-labelledby={webAppsLabelId}
className={expandedSidebarScrollAreaClassNames.viewport}
role="region"
>
<ScrollAreaContent className={expandedSidebarScrollAreaClassNames.content}>
{installedAppItems}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={expandedSidebarScrollAreaClassNames.scrollbar}>
<ScrollAreaThumb className={expandedSidebarScrollAreaClassNames.thumb} />
</ScrollAreaScrollbar>
</ScrollArea>
</div>
)
@ -140,18 +154,13 @@ const SideBar = () => {
{!isMobile && (
<div className="mt-auto flex pb-3 pt-3">
<button
type="button"
aria-label={isFold ? t('sidebar.expandSidebar', { ns: 'layout' }) : t('sidebar.collapseSidebar', { ns: 'layout' })}
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover"
onClick={toggleIsFold}
>
<div className="flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
{isFold
? <span aria-hidden="true" className="i-ri-expand-right-line" />
? <span className="i-ri-expand-right-line" />
: (
<span aria-hidden="true" className="i-ri-layout-left-2-line" />
<span className="i-ri-layout-left-2-line" />
)}
</button>
</div>
</div>
)}

View File

@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => {
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
render(<CommonCreateModal {...defaultProps} builder={builder} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
@ -1975,8 +1972,8 @@ describe('CommonCreateModal', () => {
})
mockUsePluginStore.mockReturnValue(detailWithCredentials)
// Make createBuilder slow
mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
const createBuilderPromise = Promise.withResolvers<{ subscription_builder: TriggerSubscriptionBuilder }>()
mockCreateBuilder.mockImplementation(() => createBuilderPromise.promise)
render(<CommonCreateModal {...defaultProps} />)
@ -1985,6 +1982,14 @@ describe('CommonCreateModal', () => {
// Should still attempt to verify
expect(screen.getByTestId('modal')).toBeInTheDocument()
createBuilderPromise.resolve({
subscription_builder: createMockSubscriptionBuilder(),
})
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
})
})

View File

@ -1,410 +0,0 @@
import type { Edge, Node } from '../types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../__tests__/fixtures'
import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env'
import EdgeContextmenu from '../edge-contextmenu'
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
const mockSaveStateToHistory = vi.fn()
vi.mock('../hooks/use-workflow-history', () => ({
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
WorkflowHistoryEvent: {
EdgeDelete: 'EdgeDelete',
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
},
}))
vi.mock('../hooks/use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => false,
}),
}))
vi.mock('../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils')>()
return {
...actual,
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
}
})
vi.mock('../hooks', async () => {
const { useEdgesInteractions } = await import('../hooks/use-edges-interactions')
const { usePanelInteractions } = await import('../hooks/use-panel-interactions')
return {
useEdgesInteractions,
usePanelInteractions,
}
})
type EdgeRuntimeState = {
_hovering?: boolean
_isBundled?: boolean
}
type NodeRuntimeState = {
selected?: boolean
_isBundled?: boolean
}
const getEdgeRuntimeState = (edge?: Edge): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const getNodeRuntimeState = (node?: Node): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
function createFlowNodes() {
return [
createNode({ id: 'n1' }),
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
]
}
function createFlowEdges() {
return [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
data: { _hovering: false },
selected: true,
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
]
}
let latestNodes: Node[] = []
let latestEdges: Edge[] = []
const RuntimeProbe = () => {
latestNodes = useNodes() as Node[]
latestEdges = useEdges() as Edge[]
return null
}
const hooksStoreProps = {
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
}
const EdgeMenuHarness = () => {
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
const edges = useEdges() as Edge[]
const reactFlowStore = useStoreApi()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace')
return
e.preventDefault()
handleEdgeDelete()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [handleEdgeDelete])
return (
<div>
<RuntimeProbe />
<button
type="button"
aria-label="Right-click edge e1"
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e1') as never)}
>
edge-e1
</button>
<button
type="button"
aria-label="Right-click edge e2"
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e2') as never)}
>
edge-e2
</button>
<button
type="button"
aria-label="Remove edge e1"
onClick={() => {
const { edges, setEdges } = reactFlowStore.getState()
setEdges(edges.filter(edge => edge.id !== 'e1'))
}}
>
remove-e1
</button>
<EdgeContextmenu />
</div>
)
}
function renderEdgeMenu(options?: {
nodes?: Node[]
edges?: Edge[]
initialStoreState?: Record<string, unknown>
}) {
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
return renderWorkflowFlowComponent(<EdgeMenuHarness />, {
nodes,
edges,
initialStoreState,
hooksStoreProps,
reactFlowProps: { fitView: false },
})
}
describe('EdgeContextmenu', () => {
beforeEach(() => {
vi.clearAllMocks()
latestNodes = []
latestEdges = []
})
it('should not render when edgeMenu is absent', () => {
renderWorkflowFlowComponent(<EdgeContextmenu />, {
nodes: createFlowNodes(),
edges: createFlowEdges(),
hooksStoreProps,
reactFlowProps: { fitView: false },
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should delete the menu edge and close the menu when another edge is selected', async () => {
const user = userEvent.setup()
const { store } = renderEdgeMenu({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
selected: false,
data: { _hovering: false },
}),
],
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'e2',
},
},
})
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
await user.click(deleteAction)
await waitFor(() => {
expect(latestEdges).toHaveLength(1)
expect(latestEdges[0].id).toBe('e1')
expect(latestEdges[0].selected).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('should not render the menu when the referenced edge no longer exists', () => {
renderWorkflowFlowComponent(<EdgeContextmenu />, {
nodes: createFlowNodes(),
edges: createFlowEdges(),
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'missing-edge',
},
},
hooksStoreProps,
reactFlowProps: { fitView: false },
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should open the edge menu at the right-click position', async () => {
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
renderEdgeMenu()
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 320,
clientY: 180,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
x: 320,
y: 180,
width: 0,
height: 0,
}))
})
it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
const user = userEvent.setup()
renderEdgeMenu()
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 320,
clientY: 180,
})
await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
})
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it.each([
['Delete', 'Delete'],
['Backspace', 'Backspace'],
])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
renderEdgeMenu({
nodes: [
createNode({
id: 'n1',
selected: true,
data: { selected: true, _isBundled: true },
}),
createNode({
id: 'n2',
position: { x: 100, y: 0 },
}),
],
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 240,
clientY: 120,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(document.body, { key })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2'])
expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true)
})
})
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
renderEdgeMenu({
nodes: [
createNode({
id: 'n1',
selected: true,
data: { selected: true, _isBundled: true },
}),
createNode({
id: 'n2',
position: { x: 100, y: 0 },
selected: true,
data: { selected: true, _isBundled: true },
}),
],
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
clientX: 200,
clientY: 100,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(document.body, { key: 'Delete' })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(latestEdges.map(edge => edge.id)).toEqual(['e2'])
expect(latestNodes).toHaveLength(2)
expect(latestNodes.every(node =>
!node.selected
&& !getNodeRuntimeState(node).selected
&& !getNodeRuntimeState(node)._isBundled,
)).toBe(true)
})
})
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
renderEdgeMenu()
const edgeOneButton = screen.getByLabelText('Right-click edge e1')
const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
fireEvent.contextMenu(edgeOneButton, {
clientX: 80,
clientY: 60,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.contextMenu(edgeTwoButton, {
clientX: 360,
clientY: 240,
})
await waitFor(() => {
expect(screen.getAllByRole('menu')).toHaveLength(1)
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
x: 360,
y: 240,
}))
expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false)
expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true)
expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
})
})
it('should hide the menu when the target edge disappears after opening it', async () => {
const { container } = renderEdgeMenu()
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
clientX: 160,
clientY: 100,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement)
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
})

View File

@ -2,11 +2,11 @@ import type { InputVar } from '../types'
import type { PromptVariable } from '@/models/debug'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useNodes } from 'reactflow'
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
import Features from '../features'
import { InputVarType } from '../types'
import { createStartNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
import { renderWorkflowComponent } from './workflow-test-env'
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleAddVariable = vi.fn()
@ -112,15 +112,17 @@ const DelayedFeatures = () => {
return <Features />
}
const renderFeatures = (options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>) =>
renderWorkflowFlowComponent(
<DelayedFeatures />,
{
nodes: [startNode],
edges: [],
...options,
},
const renderFeatures = (options?: Parameters<typeof renderWorkflowComponent>[1]) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={[startNode]} edges={[]} fitView />
<DelayedFeatures />
</ReactFlowProvider>
</div>,
options,
)
}
describe('Features', () => {
beforeEach(() => {

View File

@ -42,13 +42,6 @@ export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?
})
}
export function createNodeDataFactory<T extends CommonNodeType & Record<string, unknown>>(defaults: T) {
return (overrides: Partial<T> = {}): T => ({
...defaults,
...overrides,
})
}
export function createTriggerNode(
triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook,
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},

View File

@ -1,9 +0,0 @@
import { vi } from 'vitest'
export function resolveDocLink(path: string, baseUrl = 'https://docs.example.com') {
return `${baseUrl}${path}`
}
export function createDocLinkMock(baseUrl = 'https://docs.example.com') {
return vi.fn((path: string) => resolveDocLink(path, baseUrl))
}

View File

@ -1,179 +0,0 @@
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
createCredentialState,
createDefaultModel,
createModel,
createModelItem,
createProviderMeta,
} from './model-provider-fixtures'
describe('model-provider-fixtures', () => {
describe('createModelItem', () => {
it('should return the default text embedding model item', () => {
expect(createModelItem()).toEqual({
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
})
})
it('should allow overriding the default model item fields', () => {
expect(createModelItem({
model: 'bge-large',
status: ModelStatusEnum.disabled,
load_balancing_enabled: true,
})).toEqual(expect.objectContaining({
model: 'bge-large',
status: ModelStatusEnum.disabled,
load_balancing_enabled: true,
}))
})
})
describe('createModel', () => {
it('should build an active provider model with one default model item', () => {
const result = createModel()
expect(result.provider).toBe('openai')
expect(result.status).toBe(ModelStatusEnum.active)
expect(result.models).toHaveLength(1)
expect(result.models[0]).toEqual(createModelItem())
})
it('should use override values for provider metadata and model list', () => {
const customModelItem = createModelItem({
model: 'rerank-v1',
model_type: ModelTypeEnum.rerank,
})
expect(createModel({
provider: 'cohere',
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [customModelItem],
})).toEqual(expect.objectContaining({
provider: 'cohere',
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [customModelItem],
}))
})
})
describe('createDefaultModel', () => {
it('should return the default provider and model selection', () => {
expect(createDefaultModel()).toEqual({
provider: 'openai',
model: 'text-embedding-3-large',
})
})
it('should allow overriding the default provider selection', () => {
expect(createDefaultModel({
provider: 'azure_openai',
model: 'text-embedding-3-small',
})).toEqual({
provider: 'azure_openai',
model: 'text-embedding-3-small',
})
})
})
describe('createProviderMeta', () => {
it('should return provider metadata with credential and system configuration defaults', () => {
expect(createProviderMeta()).toEqual({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
},
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
supported_model_types: [ModelTypeEnum.textEmbedding],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
})
it('should apply provider metadata overrides', () => {
expect(createProviderMeta({
provider: 'bedrock',
supported_model_types: [ModelTypeEnum.textGeneration],
preferred_provider_type: PreferredProviderTypeEnum.system,
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
})).toEqual(expect.objectContaining({
provider: 'bedrock',
supported_model_types: [ModelTypeEnum.textGeneration],
preferred_provider_type: PreferredProviderTypeEnum.system,
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
}))
})
})
describe('createCredentialState', () => {
it('should return the default active credential panel state', () => {
expect(createCredentialState()).toEqual({
variant: 'api-active',
priority: 'apiKeyOnly',
supportsCredits: false,
showPrioritySwitcher: false,
isCreditsExhausted: false,
hasCredentials: true,
credentialName: undefined,
credits: 0,
})
})
it('should allow overriding the credential panel state', () => {
expect(createCredentialState({
variant: 'credits-active',
supportsCredits: true,
showPrioritySwitcher: true,
credits: 12,
credentialName: 'Primary Key',
})).toEqual(expect.objectContaining({
variant: 'credits-active',
supportsCredits: true,
showPrioritySwitcher: true,
credits: 12,
credentialName: 'Primary Key',
}))
})
})
})

View File

@ -1,97 +0,0 @@
import type {
DefaultModel,
Model,
ModelItem,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
export function createModelItem(overrides: Partial<ModelItem> = {}): ModelItem {
return {
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
}
}
export function createModel(overrides: Partial<Model> = {}): Model {
return {
provider: 'openai',
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [createModelItem()],
status: ModelStatusEnum.active,
...overrides,
}
}
export function createDefaultModel(overrides: Partial<DefaultModel> = {}): DefaultModel {
return {
provider: 'openai',
model: 'text-embedding-3-large',
...overrides,
}
}
export function createProviderMeta(overrides: Partial<ModelProvider> = {}): ModelProvider {
return {
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
},
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
supported_model_types: [ModelTypeEnum.textEmbedding],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
...overrides,
}
}
export function createCredentialState(overrides: Partial<CredentialPanelState> = {}): CredentialPanelState {
return {
variant: 'api-active',
priority: 'apiKeyOnly',
supportsCredits: false,
showPrioritySwitcher: false,
isCreditsExhausted: false,
hasCredentials: true,
credentialName: undefined,
credits: 0,
...overrides,
}
}

View File

@ -1,12 +1,16 @@
import type { EdgeChange, ReactFlowProps } from 'reactflow'
import type { Edge, Node } from '../types'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } from 'reactflow'
import { FlowType } from '@/types/common'
import { WORKFLOW_DATA_UPDATE } from '../constants'
import { Workflow } from '../index'
import { renderWorkflowComponent } from './workflow-test-env'
const reactFlowState = vi.hoisted(() => ({
lastProps: null as ReactFlowProps | null,
}))
type WorkflowUpdateEvent = {
type: string
payload: {
@ -19,10 +23,6 @@ const eventEmitterState = vi.hoisted(() => ({
subscription: null as null | ((payload: WorkflowUpdateEvent) => void),
}))
const reactFlowBridge = vi.hoisted(() => ({
store: null as null | ReturnType<typeof useStoreApi>,
}))
const workflowHookMocks = vi.hoisted(() => ({
handleNodeDragStart: vi.fn(),
handleNodeDrag: vi.fn(),
@ -52,64 +52,90 @@ const workflowHookMocks = vi.hoisted(() => ({
useWorkflowSearch: vi.fn(),
}))
function createInitializedNode(id: string, x: number, label: string) {
return {
id,
position: { x, y: 0 },
positionAbsolute: { x, y: 0 },
width: 160,
height: 40,
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { label },
[internalsSymbol]: {
positionAbsolute: { x, y: 0 },
handleBounds: {
source: [{
id: null,
nodeId: id,
type: 'source',
position: Position.Right,
x: 160,
y: 0,
width: 0,
height: 40,
}],
target: [{
id: null,
nodeId: id,
type: 'target',
position: Position.Left,
x: 0,
y: 0,
width: 0,
height: 40,
}],
},
z: 0,
},
}
}
const baseNodes = [
createInitializedNode('node-1', 0, 'Workflow node node-1'),
createInitializedNode('node-2', 240, 'Workflow node node-2'),
{
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {},
},
] as unknown as Node[]
const baseEdges = [
{
id: 'edge-1',
type: 'custom',
source: 'node-1',
target: 'node-2',
data: { sourceType: 'start', targetType: 'end' },
},
] as unknown as Edge[]
const edgeChanges: EdgeChange[] = [{ id: 'edge-1', type: 'remove' }]
function createMouseEvent() {
return {
preventDefault: vi.fn(),
clientX: 24,
clientY: 48,
} as unknown as React.MouseEvent<Element, MouseEvent>
}
vi.mock('@/next/dynamic', () => ({
default: () => () => null,
}))
vi.mock('reactflow', async () => {
const mod = await import('./reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
const ReactFlowMock = (props: ReactFlowProps) => {
reactFlowState.lastProps = props
return React.createElement(
'div',
{ 'data-testid': 'reactflow-mock' },
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edge mouse enter',
'onClick': () => props.onEdgeMouseEnter?.(createMouseEvent(), baseEdges[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edge mouse leave',
'onClick': () => props.onEdgeMouseLeave?.(createMouseEvent(), baseEdges[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edges change',
'onClick': () => props.onEdgesChange?.(edgeChanges),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edge context menu',
'onClick': () => props.onEdgeContextMenu?.(createMouseEvent(), baseEdges[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit node context menu',
'onClick': () => props.onNodeContextMenu?.(createMouseEvent(), baseNodes[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit pane context menu',
'onClick': () => props.onPaneContextMenu?.(createMouseEvent()),
}),
props.children,
)
}
return {
...base,
SelectionMode: {
Partial: 'partial',
},
ReactFlow: ReactFlowMock,
default: ReactFlowMock,
}
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
@ -140,10 +166,7 @@ vi.mock('../custom-connection-line', () => ({
}))
vi.mock('../custom-edge', () => ({
default: () => React.createElement(BaseEdge, {
id: 'edge-1',
path: 'M 0 0 L 100 0',
}),
default: () => null,
}))
vi.mock('../help-line', () => ({
@ -159,7 +182,7 @@ vi.mock('../node-contextmenu', () => ({
}))
vi.mock('../nodes', () => ({
default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`),
default: () => null,
}))
vi.mock('../nodes/data-source-empty', () => ({
@ -266,24 +289,17 @@ vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({
}),
}))
function renderSubject(options?: {
nodes?: Node[]
edges?: Edge[]
initialStoreState?: Record<string, unknown>
}) {
const { nodes = baseNodes, edges = baseEdges, initialStoreState } = options ?? {}
vi.mock('../workflow-history-store', () => ({
WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
}))
function renderSubject() {
return renderWorkflowComponent(
<ReactFlowProvider>
<Workflow
nodes={nodes}
edges={edges}
>
<ReactFlowEdgeBootstrap nodes={nodes} edges={edges} />
</Workflow>
</ReactFlowProvider>,
<Workflow
nodes={baseNodes}
edges={baseEdges}
/>,
{
initialStoreState,
hooksStoreProps: {
configsMap: {
flowId: 'flow-1',
@ -295,106 +311,75 @@ function renderSubject(options?: {
)
}
function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] }) {
const store = useStoreApi()
React.useEffect(() => {
store.setState({
edges,
width: 500,
height: 500,
nodeInternals: new Map(nodes.map(node => [node.id, node])),
})
reactFlowBridge.store = store
return () => {
reactFlowBridge.store = null
}
}, [edges, nodes, store])
return null
}
function getPane(container: HTMLElement) {
const pane = container.querySelector('.react-flow__pane') as HTMLElement | null
if (!pane)
throw new Error('Expected a rendered React Flow pane')
return pane
}
describe('Workflow edge event wiring', () => {
beforeEach(() => {
vi.clearAllMocks()
reactFlowState.lastProps = null
eventEmitterState.subscription = null
reactFlowBridge.store = null
})
it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => {
const { container } = renderSubject()
const pane = getPane(container)
it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => {
renderSubject()
act(() => {
fireEvent.contextMenu(screen.getByText('Workflow node node-1'), { clientX: 24, clientY: 48 })
fireEvent.contextMenu(pane, { clientX: 24, clientY: 48 })
})
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse enter' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse leave' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit edges change' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit edge context menu' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit node context menu' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit pane context menu' }))
await waitFor(() => {
expect(reactFlowBridge.store?.getState().onEdgesChange).toBeTypeOf('function')
})
act(() => {
reactFlowBridge.store?.getState().onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }])
})
await waitFor(() => {
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'edge-1', type: 'select' }),
]))
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), expect.objectContaining({ id: 'node-1' }))
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}))
})
expect(workflowHookMocks.handleEdgeEnter).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseEdges[0])
expect(workflowHookMocks.handleEdgeLeave).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseEdges[0])
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(edgeChanges)
expect(workflowHookMocks.handleEdgeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseEdges[0])
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseNodes[0])
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}))
})
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', async () => {
renderSubject({
edges: [
{
...baseEdges[0],
selected: true,
} as Edge,
],
})
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => {
renderSubject()
act(() => {
fireEvent.keyDown(document.body, { key: 'Delete' })
})
await waitFor(() => {
expect(screen.getByText('Workflow node node-1')).toBeInTheDocument()
})
expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'edge-1', type: 'remove' }),
]))
expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull()
})
it('should clear edgeMenu when workflow data updates remove the current edge', () => {
const { store } = renderSubject({
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'edge-1',
const { store } = renderWorkflowComponent(
<Workflow
nodes={baseNodes}
edges={baseEdges}
/>,
{
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'edge-1',
},
},
hooksStoreProps: {
configsMap: {
flowId: 'flow-1',
flowType: FlowType.appFlow,
fileSettings: {},
},
},
},
})
)
act(() => {
eventEmitterState.subscription?.({

View File

@ -4,17 +4,10 @@
import type { Shape } from '../store/workflow'
import { act, screen } from '@testing-library/react'
import * as React from 'react'
import { useNodes } from 'reactflow'
import { FlowType } from '@/types/common'
import { useHooksStore } from '../hooks-store/store'
import { useStore, useWorkflowStore } from '../store/workflow'
import { createNode } from './fixtures'
import {
renderNodeComponent,
renderWorkflowComponent,
renderWorkflowFlowComponent,
renderWorkflowFlowHook,
} from './workflow-test-env'
import { renderNodeComponent, renderWorkflowComponent } from './workflow-test-env'
// ---------------------------------------------------------------------------
// Test components that read from workflow contexts
@ -50,12 +43,6 @@ function NodeRenderer(props: { id: string, data: { title: string }, selected?: b
)
}
function FlowReader() {
const nodes = useNodes()
const showConfirm = useStore(s => s.showConfirm)
return React.createElement('div', { 'data-testid': 'flow-reader' }, `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -147,30 +134,3 @@ describe('renderNodeComponent', () => {
expect(screen.getByTestId('node-store')).toHaveTextContent('test-node-1:hand')
})
})
describe('renderWorkflowFlowComponent', () => {
it('should provide both ReactFlow and Workflow contexts', () => {
renderWorkflowFlowComponent(React.createElement(FlowReader), {
nodes: [
createNode({ id: 'n-1' }),
createNode({ id: 'n-2' }),
],
initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } },
})
expect(screen.getByTestId('flow-reader')).toHaveTextContent('2:confirm')
})
})
describe('renderWorkflowFlowHook', () => {
it('should render hooks inside a real ReactFlow provider', () => {
const { result } = renderWorkflowFlowHook(() => useNodes(), {
nodes: [
createNode({ id: 'flow-1' }),
],
})
expect(result.current).toHaveLength(1)
expect(result.current[0].id).toBe('flow-1')
})
})

View File

@ -69,7 +69,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { temporal } from 'zundo'
import { create } from 'zustand'
import { WorkflowContext } from '../context'
@ -253,104 +252,6 @@ export function renderWorkflowComponent(
return { ...renderResult, ...stores }
}
// ---------------------------------------------------------------------------
// renderWorkflowFlowComponent / renderWorkflowFlowHook — real ReactFlow wrappers
// ---------------------------------------------------------------------------
type WorkflowFlowOptions = WorkflowProviderOptions & {
nodes?: Node[]
edges?: Edge[]
reactFlowProps?: Omit<React.ComponentProps<typeof ReactFlow>, 'children' | 'nodes' | 'edges'>
canvasStyle?: React.CSSProperties
}
type WorkflowFlowComponentTestOptions = Omit<RenderOptions, 'wrapper'> & WorkflowFlowOptions
type WorkflowFlowHookTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & WorkflowFlowOptions
function createWorkflowFlowWrapper(
stores: StoreInstances,
{
historyStore: historyConfig,
nodes = [],
edges = [],
reactFlowProps,
canvasStyle,
}: WorkflowFlowOptions,
) {
const workflowWrapper = createWorkflowWrapper(stores, historyConfig)
return ({ children }: { children: React.ReactNode }) => React.createElement(
workflowWrapper,
null,
React.createElement(
'div',
{ style: { width: 800, height: 600, ...canvasStyle } },
React.createElement(
ReactFlowProvider,
null,
React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }),
children,
),
),
)
}
export function renderWorkflowFlowComponent(
ui: React.ReactElement,
options?: WorkflowFlowComponentTestOptions,
): WorkflowComponentTestResult {
const {
initialStoreState,
hooksStoreProps,
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
...renderOptions
} = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
const wrapper = createWorkflowFlowWrapper(stores, {
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
})
const renderResult = render(ui, { wrapper, ...renderOptions })
return { ...renderResult, ...stores }
}
export function renderWorkflowFlowHook<R, P = undefined>(
hook: (props: P) => R,
options?: WorkflowFlowHookTestOptions<P>,
): WorkflowHookTestResult<R, P> {
const {
initialStoreState,
hooksStoreProps,
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
...rest
} = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
const wrapper = createWorkflowFlowWrapper(stores, {
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
})
const renderResult = renderHook(hook, { wrapper, ...rest })
return { ...renderResult, ...stores }
}
// ---------------------------------------------------------------------------
// renderNodeComponent — convenience wrapper for node components
// ---------------------------------------------------------------------------

View File

@ -1,277 +0,0 @@
import type { TriggerWithProvider } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { CollectionType } from '@/app/components/tools/types'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage, useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
import { Theme } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
import useNodes from '../../store/workflow/use-nodes'
import { BlockEnum } from '../../types'
import AllStartBlocks from '../all-start-blocks'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
useLocale: vi.fn(),
}))
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: vi.fn(),
useInvalidateAllTriggerPlugins: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedTriggersRecommendations: vi.fn(),
}))
vi.mock('../../store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('../../../workflow-app/hooks', () => ({
useAvailableNodesMetaData: vi.fn(),
}))
vi.mock('@/utils/var', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/var')>()
return {
...actual,
getMarketplaceUrl: () => 'https://marketplace.test/start',
}
})
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseLocale = vi.mocked(useLocale)
const mockUseTheme = vi.mocked(useTheme)
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
const mockUseAllTriggerPlugins = vi.mocked(useAllTriggerPlugins)
const mockUseInvalidateAllTriggerPlugins = vi.mocked(useInvalidateAllTriggerPlugins)
const mockUseFeaturedTriggersRecommendations = vi.mocked(useFeaturedTriggersRecommendations)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData)
type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins>
type UseAllTriggerPluginsReturn = ReturnType<typeof useAllTriggerPlugins>
type UseFeaturedTriggersRecommendationsReturn = ReturnType<typeof useFeaturedTriggersRecommendations>
const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({
id: 'provider-1',
name: 'provider-one',
author: 'Provider Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
icon_dark: 'icon-dark',
label: { en_US: 'Provider One', zh_Hans: '提供商一' },
type: CollectionType.trigger,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@1.0.0',
meta: { version: '1.0.0' },
credentials_schema: [],
subscription_constructor: null,
subscription_schema: [],
supported_creation_methods: [],
events: [
{
name: 'created',
author: 'Provider Author',
label: { en_US: 'Created', zh_Hans: '创建' },
description: { en_US: 'Created event', zh_Hans: '创建事件' },
parameters: [],
labels: [],
output_schema: {},
},
],
...overrides,
})
const createSystemFeatures = (enableMarketplace: boolean) => ({
...defaultSystemFeatures,
enable_marketplace: enableMarketplace,
})
const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
systemFeatures: createSystemFeatures(enableMarketplace),
setSystemFeatures: vi.fn(),
})
const createMarketplacePluginsMock = (
overrides: Partial<UseMarketplacePluginsReturn> = {},
): UseMarketplacePluginsReturn => ({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
...overrides,
})
const createTriggerPluginsQueryResult = (
data: TriggerWithProvider[],
): UseAllTriggerPluginsReturn => ({
data,
error: null,
isError: false,
isPending: false,
isLoading: false,
isSuccess: true,
isFetching: false,
isRefetching: false,
isLoadingError: false,
isRefetchError: false,
isInitialLoading: false,
isPaused: false,
isEnabled: true,
status: 'success',
fetchStatus: 'idle',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve(data),
} as UseAllTriggerPluginsReturn)
const createFeaturedTriggersRecommendationsMock = (
overrides: Partial<UseFeaturedTriggersRecommendationsReturn> = {},
): UseFeaturedTriggersRecommendationsReturn => ({
plugins: [],
isLoading: false,
...overrides,
})
const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => ({
nodes: [],
} as unknown as ReturnType<typeof useAvailableNodesMetaData>)
describe('AllStartBlocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseLocale.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([createTriggerProvider()]))
mockUseInvalidateAllTriggerPlugins.mockReturnValue(vi.fn())
mockUseFeaturedTriggersRecommendations.mockReturnValue(createFeaturedTriggersRecommendationsMock())
mockUseNodes.mockReturnValue([])
mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData())
})
// The combined start tab should merge built-in blocks, trigger plugins, and marketplace states.
describe('Content Rendering', () => {
it('should render start blocks and trigger plugin actions', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<AllStartBlocks
searchText=""
onSelect={onSelect}
availableBlocksTypes={[BlockEnum.Start, BlockEnum.TriggerPlugin]}
allowUserInputSelection
/>,
)
await waitFor(() => {
expect(screen.getByText('workflow.tabs.allTriggers')).toBeInTheDocument()
})
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
expect(screen.getByText('Provider One')).toBeInTheDocument()
await user.click(screen.getByText('workflow.blocks.start'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
await user.click(screen.getByText('Provider One'))
await user.click(screen.getByText('Created'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({
provider_id: 'provider-one',
event_name: 'created',
}))
})
it('should show marketplace footer when marketplace is enabled without filters', async () => {
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
render(
<AllStartBlocks
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
/>,
)
expect(await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })).toHaveAttribute('href', 'https://marketplace.test/start')
})
})
// Empty filter states should surface the request-to-community fallback.
describe('Filtered Empty State', () => {
it('should query marketplace and show the no-results state when filters have no matches', async () => {
const queryPluginsWithDebounced = vi.fn()
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
queryPluginsWithDebounced,
}))
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([]))
render(
<AllStartBlocks
searchText="missing"
tags={['webhook']}
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
/>,
)
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
query: 'missing',
tags: ['webhook'],
category: 'trigger',
})
})
expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute(
'href',
'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
)
})
})
})

View File

@ -1,186 +0,0 @@
import type { ToolWithProvider } from '../../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import { BlockEnum } from '../../types'
import DataSources from '../data-sources'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
}))
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
}))
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins>
const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'langgenius/file',
name: 'file',
author: 'Dify',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'File Source', zh_Hans: '文件源' },
type: CollectionType.datasource,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'langgenius/file',
meta: { version: '1.0.0' },
tools: [
{
name: 'local-file',
author: 'Dify',
label: { en_US: 'Local File', zh_Hans: '本地文件' },
description: { en_US: 'Load local files', zh_Hans: '加载本地文件' },
parameters: [],
labels: [],
output_schema: {},
},
],
...overrides,
})
const createSystemFeatures = (enableMarketplace: boolean) => ({
...defaultSystemFeatures,
enable_marketplace: enableMarketplace,
})
const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
systemFeatures: createSystemFeatures(enableMarketplace),
setSystemFeatures: vi.fn(),
})
const createMarketplacePluginsMock = (
overrides: Partial<UseMarketplacePluginsReturn> = {},
): UseMarketplacePluginsReturn => ({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
...overrides,
})
describe('DataSources', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
})
// Data source tools should filter by search and normalize the default value payload.
describe('Selection', () => {
it('should add default file extensions for the built-in local file data source', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<DataSources
searchText=""
onSelect={onSelect}
dataSources={[createToolProvider()]}
/>,
)
await user.click(screen.getByText('File Source'))
await user.click(screen.getByText('Local File'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.DataSource, expect.objectContaining({
provider_name: 'file',
datasource_name: 'local-file',
datasource_label: 'Local File',
fileExtensions: expect.arrayContaining(['txt', 'pdf', 'md']),
}))
})
it('should filter providers by search text', () => {
render(
<DataSources
searchText="searchable"
onSelect={vi.fn()}
dataSources={[
createToolProvider({
id: 'searchable-provider',
name: 'searchable-provider',
label: { en_US: 'Searchable Source', zh_Hans: '可搜索源' },
tools: [{
name: 'searchable-tool',
author: 'Dify',
label: { en_US: 'Searchable Tool', zh_Hans: '可搜索工具' },
description: { en_US: 'desc', zh_Hans: '描述' },
parameters: [],
labels: [],
output_schema: {},
}],
}),
createToolProvider({
id: 'other-provider',
name: 'other-provider',
label: { en_US: 'Other Source', zh_Hans: '其他源' },
}),
]}
/>,
)
expect(screen.getByText('Searchable Source')).toBeInTheDocument()
expect(screen.queryByText('Other Source')).not.toBeInTheDocument()
})
})
// Marketplace search should only run when enabled and a search term is present.
describe('Marketplace Search', () => {
it('should query marketplace plugins for datasource search results', async () => {
const queryPluginsWithDebounced = vi.fn()
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
queryPluginsWithDebounced,
}))
render(
<DataSources
searchText="invoice"
onSelect={vi.fn()}
dataSources={[]}
/>,
)
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
query: 'invoice',
category: PluginCategoryEnum.datasource,
})
})
})
})
})

View File

@ -1,197 +0,0 @@
import type { TriggerWithProvider } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PluginCategoryEnum, SupportedCreationMethods } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { BlockEnum } from '../../types'
import FeaturedTriggers from '../featured-triggers'
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
default: () => <div data-testid="marketplace-action" />,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock('@/utils/var', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/var')>()
return {
...actual,
getMarketplaceUrl: () => 'https://marketplace.test/triggers',
}
})
const mockUseTheme = vi.mocked(useTheme)
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'trigger',
org: 'org',
author: 'author',
name: 'trigger-plugin',
plugin_id: 'plugin-1',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'plugin-1@1.0.0',
icon: 'icon',
verified: true,
label: { en_US: 'Plugin One', zh_Hans: '插件一' },
brief: { en_US: 'Brief', zh_Hans: '简介' },
description: { en_US: 'Plugin description', zh_Hans: '插件描述' },
introduction: 'Intro',
repository: 'https://example.com',
category: PluginCategoryEnum.trigger,
install_count: 12,
endpoint: { settings: [] },
tags: [{ name: 'tag' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({
id: 'provider-1',
name: 'provider-one',
author: 'Provider Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
icon_dark: 'icon-dark',
label: { en_US: 'Provider One', zh_Hans: '提供商一' },
type: CollectionType.trigger,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@1.0.0',
meta: { version: '1.0.0' },
credentials_schema: [],
subscription_constructor: null,
subscription_schema: [],
supported_creation_methods: [SupportedCreationMethods.MANUAL],
events: [
{
name: 'created',
author: 'Provider Author',
label: { en_US: 'Created', zh_Hans: '创建' },
description: { en_US: 'Created event', zh_Hans: '创建事件' },
parameters: [],
labels: [],
output_schema: {},
},
],
...overrides,
})
describe('FeaturedTriggers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
})
// The section should persist collapse state and allow expanding recommended rows.
describe('Visibility Controls', () => {
it('should persist collapse state in localStorage', async () => {
const user = userEvent.setup()
render(
<FeaturedTriggers
plugins={[]}
providerMap={new Map()}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ }))
expect(screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).not.toBeInTheDocument()
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('workflow_triggers_featured_collapsed', 'true')
})
it('should show more and show less across installed providers', async () => {
const user = userEvent.setup()
const providers = Array.from({ length: 6 }).map((_, index) => createTriggerProvider({
id: `provider-${index}`,
name: `provider-${index}`,
label: { en_US: `Provider ${index}`, zh_Hans: `提供商${index}` },
plugin_id: `plugin-${index}`,
plugin_unique_identifier: `plugin-${index}@1.0.0`,
}))
const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider]))
const plugins = providers.map(provider => createPlugin({
plugin_id: provider.plugin_id!,
latest_package_identifier: provider.plugin_unique_identifier,
}))
render(
<FeaturedTriggers
plugins={plugins}
providerMap={providerMap}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('Provider 4')).toBeInTheDocument()
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
expect(screen.getByText('Provider 5')).toBeInTheDocument()
await user.click(screen.getByText('workflow.tabs.showLessFeatured'))
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
})
})
// Rendering should cover the empty state link and installed trigger selection.
describe('Rendering and Selection', () => {
it('should render the empty state link when there are no featured plugins', () => {
render(
<FeaturedTriggers
plugins={[]}
providerMap={new Map()}
onSelect={vi.fn()}
/>,
)
expect(screen.getByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).toHaveAttribute('href', 'https://marketplace.test/triggers')
})
it('should select an installed trigger event from the featured list', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const provider = createTriggerProvider()
render(
<FeaturedTriggers
plugins={[createPlugin({ plugin_id: 'plugin-1', latest_package_identifier: 'plugin-1@1.0.0' })]}
providerMap={new Map([
['plugin-1', provider],
['plugin-1@1.0.0', provider],
])}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText('Provider One'))
await user.click(screen.getByText('Created'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({
provider_id: 'provider-one',
event_name: 'created',
event_label: 'Created',
}))
})
})
})

View File

@ -1,97 +0,0 @@
import type { ToolWithProvider } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CollectionType } from '../../../tools/types'
import IndexBar, {
CUSTOM_GROUP_NAME,
DATA_SOURCE_GROUP_NAME,
groupItems,
WORKFLOW_GROUP_NAME,
} from '../index-bar'
const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'provider-1',
name: 'Provider 1',
author: 'Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'Alpha', zh_Hans: '甲' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
tools: [],
meta: { version: '1.0.0' },
...overrides,
})
describe('IndexBar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Grouping should normalize Chinese initials, custom groups, and hash ordering.
describe('groupItems', () => {
it('should group providers by first letter and move hash to the end', () => {
const items: ToolWithProvider[] = [
createToolProvider({
id: 'alpha',
label: { en_US: 'Alpha', zh_Hans: '甲' },
type: CollectionType.builtIn,
author: 'Builtin',
}),
createToolProvider({
id: 'custom',
label: { en_US: '1Custom', zh_Hans: '1自定义' },
type: CollectionType.custom,
author: 'Custom',
}),
createToolProvider({
id: 'workflow',
label: { en_US: '中文工作流', zh_Hans: '中文工作流' },
type: CollectionType.workflow,
author: 'Workflow',
}),
createToolProvider({
id: 'source',
label: { en_US: 'Data Source', zh_Hans: '数据源' },
type: CollectionType.datasource,
author: 'Data',
}),
]
const result = groupItems(items, item => item.label.zh_Hans[0] || item.label.en_US[0] || '')
expect(result.letters).toEqual(['J', 'S', 'Z', '#'])
expect(result.groups.J.Builtin).toHaveLength(1)
expect(result.groups.Z[WORKFLOW_GROUP_NAME]).toHaveLength(1)
expect(result.groups.S[DATA_SOURCE_GROUP_NAME]).toHaveLength(1)
expect(result.groups['#'][CUSTOM_GROUP_NAME]).toHaveLength(1)
})
})
// Clicking a letter should scroll the matching section into view.
describe('Rendering', () => {
it('should call scrollIntoView for the selected letter', async () => {
const user = userEvent.setup()
const scrollIntoView = vi.fn()
const itemRefs = {
current: {
A: { scrollIntoView } as unknown as HTMLElement,
},
}
render(
<IndexBar
letters={['A']}
itemRefs={itemRefs}
/>,
)
await user.click(screen.getByText('A'))
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
})
})
})

View File

@ -1,80 +0,0 @@
import type { CommonNodeType } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
import { BlockEnum } from '../../types'
import StartBlocks from '../start-blocks'
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('../../../workflow-app/hooks', () => ({
useAvailableNodesMetaData: vi.fn(),
}))
const mockUseNodes = vi.mocked(useNodes)
const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData)
const createNode = (type: BlockEnum) => ({
data: { type } as Pick<CommonNodeType, 'type'>,
}) as ReturnType<typeof useNodes>[number]
const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => ({
nodes: [],
} as unknown as ReturnType<typeof useAvailableNodesMetaData>)
describe('StartBlocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNodes.mockReturnValue([])
mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData())
})
// Start block selection should respect available types and workflow state.
describe('Filtering and Selection', () => {
it('should render available start blocks and forward selection', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onContentStateChange = vi.fn()
render(
<StartBlocks
searchText=""
onSelect={onSelect}
availableBlocksTypes={[BlockEnum.Start, BlockEnum.TriggerWebhook]}
onContentStateChange={onContentStateChange}
/>,
)
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
expect(screen.getByText('workflow.blocks.trigger-webhook')).toBeInTheDocument()
expect(screen.getByText('workflow.blocks.originalStartNode')).toBeInTheDocument()
expect(onContentStateChange).toHaveBeenCalledWith(true)
await user.click(screen.getByText('workflow.blocks.start'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
})
it('should hide user input when a start node already exists or hideUserInput is enabled', () => {
const onContentStateChange = vi.fn()
mockUseNodes.mockReturnValue([createNode(BlockEnum.Start)])
const { container } = render(
<StartBlocks
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.Start]}
onContentStateChange={onContentStateChange}
hideUserInput
/>,
)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByText('workflow.blocks.start')).not.toBeInTheDocument()
expect(onContentStateChange).toHaveBeenCalledWith(false)
})
})
})

View File

@ -0,0 +1,340 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { resetReactFlowMockState, rfState } from './__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from './__tests__/workflow-test-env'
import EdgeContextmenu from './edge-contextmenu'
import { useEdgesInteractions } from './hooks/use-edges-interactions'
vi.mock('reactflow', async () =>
(await import('./__tests__/reactflow-mock-state')).createReactFlowModuleMock())
const mockSaveStateToHistory = vi.fn()
vi.mock('./hooks/use-workflow-history', () => ({
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
WorkflowHistoryEvent: {
EdgeDelete: 'EdgeDelete',
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
},
}))
vi.mock('./hooks/use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => false,
}),
}))
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils')>()
return {
...actual,
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
}
})
vi.mock('./hooks', async () => {
const { useEdgesInteractions } = await import('./hooks/use-edges-interactions')
const { usePanelInteractions } = await import('./hooks/use-panel-interactions')
return {
useEdgesInteractions,
usePanelInteractions,
}
})
describe('EdgeContextmenu', () => {
const hooksStoreProps = {
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
}
type TestNode = typeof rfState.nodes[number] & {
selected?: boolean
data: {
selected?: boolean
_isBundled?: boolean
}
}
type TestEdge = typeof rfState.edges[number] & {
selected?: boolean
}
const createNode = (id: string, selected = false): TestNode => ({
id,
position: { x: 0, y: 0 },
data: { selected },
selected,
})
const createEdge = (id: string, selected = false): TestEdge => ({
id,
source: 'n1',
target: 'n2',
data: {},
selected,
})
const EdgeMenuHarness = () => {
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace')
return
e.preventDefault()
handleEdgeDelete()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [handleEdgeDelete])
return (
<div>
<button
type="button"
aria-label="Right-click edge e1"
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e1') as never)}
>
edge-e1
</button>
<button
type="button"
aria-label="Right-click edge e2"
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e2') as never)}
>
edge-e2
</button>
<EdgeContextmenu />
</div>
)
}
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
rfState.nodes = [
createNode('n1'),
createNode('n2'),
]
rfState.edges = [
createEdge('e1', true) as typeof rfState.edges[number] & { selected: boolean },
createEdge('e2'),
]
rfState.setNodes.mockImplementation((nextNodes) => {
rfState.nodes = nextNodes as typeof rfState.nodes
})
rfState.setEdges.mockImplementation((nextEdges) => {
rfState.edges = nextEdges as typeof rfState.edges
})
})
it('should not render when edgeMenu is absent', () => {
renderWorkflowComponent(<EdgeContextmenu />, {
hooksStoreProps,
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should delete the menu edge and close the menu when another edge is selected', async () => {
const user = userEvent.setup()
;(rfState.edges[0] as Record<string, unknown>).selected = true
;(rfState.edges[1] as Record<string, unknown>).selected = false
const { store } = renderWorkflowComponent(<EdgeContextmenu />, {
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'e2',
},
},
hooksStoreProps,
})
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
await user.click(deleteAction)
const updatedEdges = rfState.setEdges.mock.calls.at(-1)?.[0]
expect(updatedEdges).toHaveLength(1)
expect(updatedEdges[0].id).toBe('e1')
expect(updatedEdges[0].selected).toBe(true)
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
await waitFor(() => {
expect(store.getState().edgeMenu).toBeUndefined()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
it('should not render the menu when the referenced edge no longer exists', () => {
renderWorkflowComponent(<EdgeContextmenu />, {
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'missing-edge',
},
},
hooksStoreProps,
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should open the edge menu at the right-click position', async () => {
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
renderWorkflowComponent(<EdgeMenuHarness />, {
hooksStoreProps,
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 320,
clientY: 180,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
x: 320,
y: 180,
width: 0,
height: 0,
}))
})
it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
const user = userEvent.setup()
renderWorkflowComponent(<EdgeMenuHarness />, {
hooksStoreProps,
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 320,
clientY: 180,
})
await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it.each([
['Delete', 'Delete'],
['Backspace', 'Backspace'],
])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
renderWorkflowComponent(<EdgeMenuHarness />, {
hooksStoreProps,
})
rfState.nodes = [createNode('n1', true), createNode('n2')]
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 240,
clientY: 120,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(document, { key })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
expect(rfState.nodes.map(node => node.id)).toEqual(['n1', 'n2'])
expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected)).toBe(true)
})
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
renderWorkflowComponent(<EdgeMenuHarness />, {
hooksStoreProps,
})
rfState.nodes = [
{ ...createNode('n1', true), data: { selected: true, _isBundled: true } },
{ ...createNode('n2', true), data: { selected: true, _isBundled: true } },
]
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
clientX: 200,
clientY: 100,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Delete' })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(rfState.edges.map(edge => edge.id)).toEqual(['e2'])
expect(rfState.nodes).toHaveLength(2)
expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected && !node.data._isBundled)).toBe(true)
})
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
renderWorkflowComponent(<EdgeMenuHarness />, {
hooksStoreProps,
})
const edgeOneButton = screen.getByLabelText('Right-click edge e1')
const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
fireEvent.contextMenu(edgeOneButton, {
clientX: 80,
clientY: 60,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.contextMenu(edgeTwoButton, {
clientX: 360,
clientY: 240,
})
await waitFor(() => {
expect(screen.getAllByRole('menu')).toHaveLength(1)
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
x: 360,
y: 240,
}))
expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e1')?.selected).toBe(false)
expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e2')?.selected).toBe(true)
})
})
it('should hide the menu when the target edge disappears after opening it', async () => {
const { store } = renderWorkflowComponent(<EdgeMenuHarness />, {
hooksStoreProps,
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
clientX: 160,
clientY: 100,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
rfState.edges = [createEdge('e2')]
store.setState({
edgeMenu: {
clientX: 160,
clientY: 100,
edgeId: 'e1',
},
})
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../../types'
import WorkflowChecklist from '../index'
import { BlockEnum } from '../../types'
import WorkflowChecklist from './index'
let mockChecklistItems = [
{
@ -40,7 +40,7 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => [],
}))
vi.mock('../../../hooks', () => ({
vi.mock('../../hooks', () => ({
useChecklist: () => mockChecklistItems,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
@ -57,11 +57,11 @@ vi.mock('@/app/components/base/ui/popover', () => ({
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
}))
vi.mock('../plugin-group', () => ({
vi.mock('./plugin-group', () => ({
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
}))
vi.mock('../node-group', () => ({
vi.mock('./node-group', () => ({
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
{item.title}

View File

@ -1,12 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../../types'
import { ChecklistNodeGroup } from '../node-group'
import { BlockEnum } from '../../types'
import { ChecklistNodeGroup } from './node-group'
vi.mock('../../../block-icon', () => ({
vi.mock('../../block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('../item-indicator', () => ({
vi.mock('./item-indicator', () => ({
ItemIndicator: () => <div data-testid="item-indicator" />,
}))

View File

@ -1,10 +1,10 @@
import type { ChecklistItem } from '../../../hooks/use-checklist'
import type { ChecklistItem } from '../../hooks/use-checklist'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { Popover, PopoverContent } from '@/app/components/base/ui/popover'
import { useStore as usePluginDependencyStore } from '../../../plugin-dependency/store'
import { BlockEnum } from '../../../types'
import { ChecklistPluginGroup } from '../plugin-group'
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
import { BlockEnum } from '../../types'
import { ChecklistPluginGroup } from './plugin-group'
const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistItem => ({
id: 'node-1',

View File

@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RunMode from '../run-mode'
import { TriggerType } from '../test-run-menu'
import RunMode from './run-mode'
import { TriggerType } from './test-run-menu'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
@ -42,7 +42,7 @@ vi.mock('@/app/components/workflow/store', () => ({
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))
@ -72,8 +72,8 @@ vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-circle" />,
}))
vi.mock('../test-run-menu', async (importOriginal) => {
const actual = await importOriginal<typeof import('../test-run-menu')>()
vi.mock('./test-run-menu', async (importOriginal) => {
const actual = await importOriginal<typeof import('./test-run-menu')>()
return {
...actual,
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {

View File

@ -1,17 +1,10 @@
import type { Node } from '../../types'
import { act, waitFor } from '@testing-library/react'
import { useNodes } from 'reactflow'
import { createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { BlockEnum } from '../../types'
import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url'
type WebhookFlowNode = Node & {
data: NonNullable<Node['data']> & {
webhook_url?: string
webhook_debug_url?: string
}
}
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('@/app/components/app/store', async () =>
(await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }))
@ -22,29 +15,13 @@ vi.mock('@/service/apps', () => ({
}))
describe('useAutoGenerateWebhookUrl', () => {
const createFlowNodes = (): WebhookFlowNode[] => [
createNode({
id: 'webhook-1',
data: { type: BlockEnum.TriggerWebhook, webhook_url: '' },
}) as WebhookFlowNode,
createNode({
id: 'code-1',
position: { x: 300, y: 0 },
data: { type: BlockEnum.Code },
}) as WebhookFlowNode,
]
const renderAutoGenerateWebhookUrlHook = () =>
renderWorkflowFlowHook(() => ({
autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
nodes: useNodes<WebhookFlowNode>(),
}), {
nodes: createFlowNodes(),
edges: [],
})
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
rfState.nodes = [
{ id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } },
{ id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } },
]
})
it('should fetch and set webhook URL for a webhook trigger node', async () => {
@ -53,63 +30,38 @@ describe('useAutoGenerateWebhookUrl', () => {
webhook_debug_url: 'https://example.com/webhook-debug',
})
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('webhook-1')
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' })
expect(rfState.setNodes).toHaveBeenCalledOnce()
await waitFor(() => {
const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
expect(webhookNode?.data.webhook_url).toBe('https://example.com/webhook')
expect(webhookNode?.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1')
expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook')
expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
})
it('should not fetch when node is not a webhook trigger', async () => {
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('code-1')
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('code-1')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
const codeNode = result.current.nodes.find(node => node.id === 'code-1') as WebhookFlowNode | undefined
expect(codeNode?.data.webhook_url).toBeUndefined()
expect(rfState.setNodes).not.toHaveBeenCalled()
})
it('should not fetch when node does not exist', async () => {
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('nonexistent')
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('nonexistent')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should not fetch when webhook_url already exists', async () => {
const { result } = renderWorkflowFlowHook(() => ({
autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
}), {
nodes: [
createNode({
id: 'webhook-1',
data: {
type: BlockEnum.TriggerWebhook,
webhook_url: 'https://existing.com/webhook',
},
}) as WebhookFlowNode,
],
edges: [],
})
rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook'
await act(async () => {
await result.current.autoGenerateWebhookUrl('webhook-1')
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
@ -118,18 +70,14 @@ describe('useAutoGenerateWebhookUrl', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchWebhookUrl.mockRejectedValue(new Error('network error'))
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('webhook-1')
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to auto-generate webhook URL:',
expect.any(Error),
)
const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
expect(webhookNode?.data.webhook_url).toBe('')
expect(rfState.setNodes).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})

View File

@ -1,9 +1,10 @@
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useEdgesInteractions } from '../use-edges-interactions'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
// useWorkflowHistory uses a debounced save — mock for synchronous assertions
const mockSaveStateToHistory = vi.fn()
vi.mock('../use-workflow-history', () => ({
@ -27,67 +28,12 @@ vi.mock('../../utils', () => ({
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
}))
type EdgeRuntimeState = {
_hovering?: boolean
_isBundled?: boolean
}
type NodeRuntimeState = {
selected?: boolean
_isBundled?: boolean
}
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
function createFlowNodes() {
return [
createNode({ id: 'n1' }),
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
]
}
function createFlowEdges() {
return [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
]
}
function renderEdgesInteractions(options?: {
nodes?: ReturnType<typeof createFlowNodes>
edges?: ReturnType<typeof createFlowEdges>
initialStoreState?: Record<string, unknown>
}) {
// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps
function renderEdgesInteractions() {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
return {
...renderWorkflowFlowHook(() => ({
...useEdgesInteractions(),
nodes: useNodes(),
edges: useEdges(),
}), {
nodes,
edges,
initialStoreState,
...renderWorkflowHook(() => useEdgesInteractions(), {
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
reactFlowProps: { fitView: false },
}),
mockDoSync,
}
@ -96,105 +42,73 @@ function renderEdgesInteractions(options?: {
describe('useEdgesInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockReadOnly = false
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 100, y: 0 }, data: {} },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } },
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } },
]
})
it('handleEdgeEnter should set _hovering to true', async () => {
it('handleEdgeEnter should set _hovering to true', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
act(() => {
result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
})
await waitFor(() => {
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(true)
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e2'))._hovering).toBe(false)
})
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true)
expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false)
})
it('handleEdgeLeave should set _hovering to false', async () => {
const { result } = renderEdgesInteractions({
edges: createFlowEdges().map(edge =>
edge.id === 'e1'
? createEdge({ ...edge, data: { ...edge.data, _hovering: true } })
: edge,
),
})
act(() => {
result.current.handleEdgeLeave({} as never, result.current.edges[0] as never)
})
await waitFor(() => {
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(false)
})
})
it('handleEdgesChange should update edge.selected for select changes', async () => {
it('handleEdgeLeave should set _hovering to false', () => {
rfState.edges[0].data._hovering = true
const { result } = renderEdgesInteractions()
result.current.handleEdgeLeave({} as never, rfState.edges[0] as never)
act(() => {
result.current.handleEdgesChange([
{ type: 'select', id: 'e1', selected: true },
{ type: 'select', id: 'e2', selected: false },
])
})
await waitFor(() => {
expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(true)
expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(false)
})
expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false)
})
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', async () => {
it('handleEdgesChange should update edge.selected for select changes', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgesChange([
{ type: 'select', id: 'e1', selected: true },
{ type: 'select', id: 'e2', selected: false },
])
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true)
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
})
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', () => {
const preventDefault = vi.fn()
const { result, store } = renderEdgesInteractions({
nodes: [
createNode({
id: 'n1',
data: { selected: true, _isBundled: true },
selected: true,
}),
createNode({
id: 'n2',
position: { x: 100, y: 0 },
data: { _isBundled: true },
}),
],
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
data: { _hovering: false, _isBundled: true },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false, _isBundled: true },
}),
],
})
const { result, store } = renderEdgesInteractions()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { selected: true, _isBundled: true }, selected: true } as typeof rfState.nodes[number] & { selected: boolean },
{ id: 'n2', position: { x: 100, y: 0 }, data: { _isBundled: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false, _isBundled: true } },
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false, _isBundled: true } },
]
act(() => {
result.current.handleEdgeContextMenu({
preventDefault,
clientX: 320,
clientY: 180,
} as never, result.current.edges[1] as never)
})
result.current.handleEdgeContextMenu({
preventDefault,
clientX: 320,
clientY: 180,
} as never, rfState.edges[1] as never)
expect(preventDefault).toHaveBeenCalled()
await waitFor(() => {
expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(false)
expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(true)
expect(result.current.edges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
expect(result.current.nodes.every(node => !getNodeRuntimeState(node).selected && !node.selected && !getNodeRuntimeState(node)._isBundled)).toBe(true)
})
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(false)
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(true)
expect(updated.every((e: { data: { _isBundled?: boolean } }) => !e.data._isBundled)).toBe(true)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes.every((node: { data: { selected?: boolean, _isBundled?: boolean }, selected?: boolean }) => !node.data.selected && !node.selected && !node.data._isBundled)).toBe(true)
expect(store.getState().edgeMenu).toEqual({
clientX: 320,
@ -206,133 +120,70 @@ describe('useEdgesInteractions', () => {
expect(store.getState().selectionMenu).toBeUndefined()
})
it('handleEdgeDelete should remove selected edge and trigger sync + history', async () => {
const { result, store } = renderEdgesInteractions({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
],
initialStoreState: {
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result, store } = renderEdgesInteractions()
store.setState({
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
})
act(() => {
result.current.handleEdgeDelete()
})
await waitFor(() => {
expect(result.current.edges).toHaveLength(1)
expect(result.current.edges[0]?.id).toBe('e2')
})
result.current.handleEdgeDelete()
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e2')
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDelete should do nothing when no edge is selected', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeDelete()
})
expect(result.current.edges).toHaveLength(2)
result.current.handleEdgeDelete()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', async () => {
const { result, store } = renderEdgesInteractions({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
],
initialStoreState: {
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
},
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result, store } = renderEdgesInteractions()
store.setState({
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
})
act(() => {
result.current.handleEdgeDeleteById('e2')
})
await waitFor(() => {
expect(result.current.edges).toHaveLength(1)
expect(result.current.edges[0]?.id).toBe('e1')
expect(result.current.edges[0]?.selected).toBe(true)
})
result.current.handleEdgeDeleteById('e2')
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e1')
expect(updated[0].selected).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => {
const { result, store } = renderEdgesInteractions({
initialStoreState: {
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
})
act(() => {
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
})
await waitFor(() => {
expect(result.current.edges).toHaveLength(1)
expect(result.current.edges[0]?.id).toBe('e2')
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => {
const { result, store } = renderEdgesInteractions()
store.setState({
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
})
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e2')
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
})
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', async () => {
const { result } = renderEdgesInteractions({
edges: [
createEdge({
id: 'n1-old-handle-n2-target',
source: 'n1',
target: 'n2',
sourceHandle: 'old-handle',
targetHandle: 'target',
data: {},
}),
],
})
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => {
rfState.edges = [
{ id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0],
]
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
})
const { result } = renderEdgesInteractions()
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
await waitFor(() => {
expect(result.current.edges[0]?.sourceHandle).toBe('new-handle')
expect(result.current.edges[0]?.id).toBe('n1-new-handle-n2-target')
})
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated[0].sourceHandle).toBe('new-handle')
expect(updated[0].id).toBe('n1-new-handle-n2-target')
})
describe('read-only mode', () => {
@ -342,75 +193,38 @@ describe('useEdgesInteractions', () => {
it('handleEdgeEnter should do nothing', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
})
expect(getEdgeRuntimeState(result.current.edges[0])._hovering).toBe(false)
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDelete should do nothing', () => {
const { result } = renderEdgesInteractions({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
],
})
act(() => {
result.current.handleEdgeDelete()
})
expect(result.current.edges).toHaveLength(2)
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result } = renderEdgesInteractions()
result.current.handleEdgeDelete()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDeleteById should do nothing', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeDeleteById('e1')
})
expect(result.current.edges).toHaveLength(2)
result.current.handleEdgeDeleteById('e1')
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeContextMenu should do nothing', () => {
const { result, store } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeContextMenu({
preventDefault: vi.fn(),
clientX: 200,
clientY: 120,
} as never, result.current.edges[0] as never)
})
expect(result.current.edges.every(edge => !edge.selected)).toBe(true)
result.current.handleEdgeContextMenu({
preventDefault: vi.fn(),
clientX: 200,
clientY: 120,
} as never, rfState.edges[0] as never)
expect(rfState.setEdges).not.toHaveBeenCalled()
expect(store.getState().edgeMenu).toBeUndefined()
})
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
})
expect(result.current.edges).toHaveLength(2)
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
expect(rfState.setEdges).not.toHaveBeenCalled()
})
})
})

View File

@ -1,52 +1,58 @@
import type * as React from 'react'
import type { OnSelectionChangeParams } from 'reactflow'
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import type { Node, OnSelectionChangeParams } from 'reactflow'
import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useSelectionInteractions } from '../use-selection-interactions'
type BundledState = {
_isBundled?: boolean
}
const rfStoreExtra = vi.hoisted(() => ({
userSelectionRect: null as { x: number, y: number, width: number, height: number } | null,
userSelectionActive: false,
resetSelectedElements: vi.fn(),
setState: vi.fn(),
}))
const getBundledState = (item?: { data?: unknown }): BundledState =>
(item?.data ?? {}) as BundledState
function createFlowNodes() {
return [
createNode({ id: 'n1', data: { _isBundled: true } }),
createNode({ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } }),
createNode({ id: 'n3', position: { x: 200, y: 200 }, data: {} }),
]
}
function createFlowEdges() {
return [
createEdge({ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } }),
createEdge({ id: 'e2', source: 'n2', target: 'n3', data: {} }),
]
}
function renderSelectionInteractions(initialStoreState?: Record<string, unknown>) {
return renderWorkflowFlowHook(() => ({
...useSelectionInteractions(),
nodes: useNodes(),
edges: useEdges(),
reactFlowStore: useStoreApi(),
}), {
nodes: createFlowNodes(),
edges: createFlowEdges(),
reactFlowProps: { fitView: false },
initialStoreState,
})
}
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStoreApi: vi.fn(() => ({
getState: () => ({
getNodes: () => mod.rfState.nodes,
setNodes: mod.rfState.setNodes,
edges: mod.rfState.edges,
setEdges: mod.rfState.setEdges,
transform: mod.rfState.transform,
userSelectionRect: rfStoreExtra.userSelectionRect,
userSelectionActive: rfStoreExtra.userSelectionActive,
resetSelectedElements: rfStoreExtra.resetSelectedElements,
}),
setState: rfStoreExtra.setState,
subscribe: vi.fn().mockReturnValue(vi.fn()),
})),
}
})
describe('useSelectionInteractions', () => {
let container: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
rfStoreExtra.userSelectionRect = null
rfStoreExtra.userSelectionActive = false
rfStoreExtra.resetSelectedElements = vi.fn()
rfStoreExtra.setState.mockReset()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } },
{ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } },
{ id: 'n3', position: { x: 200, y: 200 }, data: {} },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } },
{ id: 'e2', source: 'n2', target: 'n3', data: {} },
]
container = document.createElement('div')
container.id = 'workflow-container'
@ -67,137 +73,110 @@ describe('useSelectionInteractions', () => {
container.remove()
})
it('handleSelectionStart should clear _isBundled from all nodes and edges', async () => {
const { result } = renderSelectionInteractions()
it('handleSelectionStart should clear _isBundled from all nodes and edges', () => {
const { result } = renderWorkflowHook(() => useSelectionInteractions())
act(() => {
result.current.handleSelectionStart()
})
result.current.handleSelectionStart()
await waitFor(() => {
expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
})
it('handleSelectionChange should mark selected nodes as bundled', async () => {
const { result } = renderSelectionInteractions()
it('handleSelectionChange should mark selected nodes as bundled', () => {
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
act(() => {
result.current.reactFlowStore.setState({
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
} as never)
})
const { result } = renderWorkflowHook(() => useSelectionInteractions())
act(() => {
result.current.handleSelectionChange({
nodes: [{ id: 'n1' }, { id: 'n3' }],
edges: [],
} as unknown as OnSelectionChangeParams)
})
result.current.handleSelectionChange({
nodes: [{ id: 'n1' }, { id: 'n3' }],
edges: [],
} as unknown as OnSelectionChangeParams)
await waitFor(() => {
expect(getBundledState(result.current.nodes.find(node => node.id === 'n1'))._isBundled).toBe(true)
expect(getBundledState(result.current.nodes.find(node => node.id === 'n2'))._isBundled).toBe(false)
expect(getBundledState(result.current.nodes.find(node => node.id === 'n3'))._isBundled).toBe(true)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true)
expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false)
expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true)
})
it('handleSelectionChange should mark selected edges', async () => {
const { result } = renderSelectionInteractions()
it('handleSelectionChange should mark selected edges', () => {
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
act(() => {
result.current.reactFlowStore.setState({
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
} as never)
})
const { result } = renderWorkflowHook(() => useSelectionInteractions())
act(() => {
result.current.handleSelectionChange({
nodes: [],
edges: [{ id: 'e1' }],
} as unknown as OnSelectionChangeParams)
})
result.current.handleSelectionChange({
nodes: [],
edges: [{ id: 'e1' }],
} as unknown as OnSelectionChangeParams)
await waitFor(() => {
expect(getBundledState(result.current.edges.find(edge => edge.id === 'e1'))._isBundled).toBe(true)
expect(getBundledState(result.current.edges.find(edge => edge.id === 'e2'))._isBundled).toBe(false)
})
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true)
expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false)
})
it('handleSelectionDrag should sync node positions', async () => {
const { result, store } = renderSelectionInteractions()
it('handleSelectionDrag should sync node positions', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
const draggedNodes = [
{ id: 'n1', position: { x: 50, y: 60 }, data: {} },
] as never
] as unknown as Node[]
act(() => {
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
})
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
expect(store.getState().nodeAnimation).toBe(false)
await waitFor(() => {
expect(result.current.nodes.find(node => node.id === 'n1')?.position).toEqual({ x: 50, y: 60 })
expect(result.current.nodes.find(node => node.id === 'n2')?.position).toEqual({ x: 100, y: 100 })
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 })
expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 })
})
it('handleSelectionCancel should clear all selection state', async () => {
const { result } = renderSelectionInteractions()
it('handleSelectionCancel should clear all selection state', () => {
const { result } = renderWorkflowHook(() => useSelectionInteractions())
act(() => {
result.current.reactFlowStore.setState({
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
userSelectionActive: false,
} as never)
result.current.handleSelectionCancel()
expect(rfStoreExtra.setState).toHaveBeenCalledWith({
userSelectionRect: null,
userSelectionActive: true,
})
act(() => {
result.current.handleSelectionCancel()
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
expect(result.current.reactFlowStore.getState().userSelectionRect).toBeNull()
expect(result.current.reactFlowStore.getState().userSelectionActive).toBe(true)
await waitFor(() => {
expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
})
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
})
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
const { result, store } = renderSelectionInteractions({
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
panelMenu: { top: 30, left: 40 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
initialStoreState: {
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
panelMenu: { top: 30, left: 40 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
})
const wrongTarget = document.createElement('div')
wrongTarget.classList.add('some-other-class')
act(() => {
result.current.handleSelectionContextMenu({
target: wrongTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
})
result.current.handleSelectionContextMenu({
target: wrongTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
expect(store.getState().selectionMenu).toBeUndefined()
const correctTarget = document.createElement('div')
correctTarget.classList.add('react-flow__nodesselection-rect')
act(() => {
result.current.handleSelectionContextMenu({
target: correctTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
})
result.current.handleSelectionContextMenu({
target: correctTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
expect(store.getState().selectionMenu).toEqual({
top: 150,
@ -209,13 +188,11 @@ describe('useSelectionInteractions', () => {
})
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
const { result, store } = renderSelectionInteractions({
selectionMenu: { top: 50, left: 60 },
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
initialStoreState: { selectionMenu: { top: 50, left: 60 } },
})
act(() => {
result.current.handleSelectionContextmenuCancel()
})
result.current.handleSelectionContextmenuCancel()
expect(store.getState().selectionMenu).toBeUndefined()
})

View File

@ -1,209 +1,130 @@
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { NodeRunningStatus } from '../../types'
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
type NodeRuntimeState = {
_runningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useEdgesInteractionsWithoutSync', () => {
const createFlowNodes = () => [
createNode({ id: 'a' }),
createNode({ id: 'b' }),
createNode({ id: 'c' }),
]
const createFlowEdges = () => [
createEdge({
id: 'e1',
source: 'a',
target: 'b',
data: {
_sourceRunningStatus: NodeRunningStatus.Running,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: true,
},
}),
createEdge({
id: 'e2',
source: 'b',
target: 'c',
data: {
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: undefined,
_waitingRun: false,
},
}),
]
const renderEdgesInteractionsHook = () =>
renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges: createFlowEdges(),
})
beforeEach(() => {
resetReactFlowMockState()
rfState.edges = [
{ id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } },
{ id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } },
]
})
it('should clear running status and waitingRun on all edges', () => {
const { result } = renderEdgesInteractionsHook()
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
result.current.handleEdgeCancelRunningStatus()
return waitFor(() => {
result.current.edges.forEach((edge) => {
const edgeState = getEdgeRuntimeState(edge)
expect(edgeState._sourceRunningStatus).toBeUndefined()
expect(edgeState._targetRunningStatus).toBeUndefined()
expect(edgeState._waitingRun).toBe(false)
})
})
expect(rfState.setEdges).toHaveBeenCalledOnce()
const updated = rfState.setEdges.mock.calls[0][0]
for (const edge of updated) {
expect(edge.data._sourceRunningStatus).toBeUndefined()
expect(edge.data._targetRunningStatus).toBeUndefined()
expect(edge.data._waitingRun).toBe(false)
}
})
it('should not mutate original edges', () => {
const edges = createFlowEdges()
const originalData = { ...getEdgeRuntimeState(edges[0]) }
const { result } = renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges,
})
const originalData = { ...rfState.edges[0].data }
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
result.current.handleEdgeCancelRunningStatus()
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
})
})
describe('useNodesInteractionsWithoutSync', () => {
const createFlowNodes = () => [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
]
const renderNodesInteractionsHook = () =>
renderWorkflowFlowHook(() => ({
...useNodesInteractionsWithoutSync(),
nodes: useNodes(),
}), {
nodes: createFlowNodes(),
edges: [],
})
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } },
{ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } },
{ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } },
]
})
describe('handleNodeCancelRunningStatus', () => {
it('should clear _runningStatus and _waitingRun on all nodes', async () => {
const { result } = renderNodesInteractionsHook()
it('should clear _runningStatus and _waitingRun on all nodes', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
act(() => {
result.current.handleNodeCancelRunningStatus()
})
result.current.handleNodeCancelRunningStatus()
await waitFor(() => {
result.current.nodes.forEach((node) => {
const nodeState = getNodeRuntimeState(node)
expect(nodeState._runningStatus).toBeUndefined()
expect(nodeState._waitingRun).toBe(false)
})
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
for (const node of updated) {
expect(node.data._runningStatus).toBeUndefined()
expect(node.data._waitingRun).toBe(false)
}
})
})
describe('handleCancelAllNodeSuccessStatus', () => {
it('should clear _runningStatus only for Succeeded nodes', async () => {
const { result } = renderNodesInteractionsHook()
it('should clear _runningStatus only for Succeeded nodes', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
result.current.handleCancelAllNodeSuccessStatus()
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
const n2 = result.current.nodes.find(node => node.id === 'n2')
const n3 = result.current.nodes.find(node => node.id === 'n3')
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
const n3 = updated.find((n: { id: string }) => n.id === 'n3')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
})
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n2.data._runningStatus).toBeUndefined()
expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed)
})
it('should not modify _waitingRun', async () => {
const { result } = renderNodesInteractionsHook()
it('should not modify _waitingRun', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
result.current.handleCancelAllNodeSuccessStatus()
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
})
const updated = rfState.setNodes.mock.calls[0][0]
expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true)
expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true)
})
})
describe('handleCancelNodeSuccessStatus', () => {
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', async () => {
const { result } = renderNodesInteractionsHook()
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
result.current.handleCancelNodeSuccessStatus('n2')
await waitFor(() => {
const n2 = result.current.nodes.find(node => node.id === 'n2')
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
expect(n2.data._runningStatus).toBeUndefined()
expect(n2.data._waitingRun).toBe(false)
})
it('should not modify nodes that are not Succeeded', async () => {
const { result } = renderNodesInteractionsHook()
it('should not modify nodes that are not Succeeded', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
act(() => {
result.current.handleCancelNodeSuccessStatus('n1')
})
result.current.handleCancelNodeSuccessStatus('n1')
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
})
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(true)
})
it('should not modify other nodes', async () => {
const { result } = renderNodesInteractionsHook()
it('should not modify other nodes', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
result.current.handleCancelNodeSuccessStatus('n2')
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
})
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
})
})
})

View File

@ -7,10 +7,8 @@ import type {
NodeFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
@ -21,100 +19,44 @@ import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
type NodeRuntimeState = {
_waitingRun?: boolean
_runningStatus?: NodeRunningStatus
_retryIndex?: number
_iterationIndex?: number
_loopIndex?: number
_runningBranchId?: string
}
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
function createRunNodes() {
return [
createNode({
id: 'n1',
width: 200,
height: 80,
data: { _waitingRun: false },
}),
]
}
function createRunEdges() {
return [
createEdge({
id: 'e1',
source: 'n0',
target: 'n1',
data: {},
}),
]
}
function renderRunEventHook<T extends Record<string, unknown>>(
useHook: () => T,
options?: {
nodes?: ReturnType<typeof createRunNodes>
edges?: ReturnType<typeof createRunEdges>
initialStoreState?: Record<string, unknown>
},
) {
const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
return renderWorkflowFlowHook(() => ({
...useHook(),
nodes: useNodes(),
edges: useEdges(),
}), {
nodes,
edges,
reactFlowProps: { fitView: false },
initialStoreState,
})
}
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
describe('useWorkflowStarted', () => {
it('should initialize workflow running data and reset nodes/edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should initialize workflow running data and reset nodes/edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
} as WorkflowStartedResponse)
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
} as WorkflowStartedResponse)
const state = store.getState().workflowRunningData!
expect(state.task_id).toBe('task-2')
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
expect(state.resultText).toBe('')
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._waitingRun).toBe(true)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should resume from Paused without resetting nodes/edges', () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
@ -122,28 +64,30 @@ describe('useWorkflowStarted', () => {
},
})
act(() => {
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
} as WorkflowStartedResponse)
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
} as WorkflowStartedResponse)
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
expect(rfState.setNodes).not.toHaveBeenCalled()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
})
describe('useWorkflowNodeFinished', () => {
it('should update tracing and node running status', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing and node running status', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@ -151,29 +95,20 @@ describe('useWorkflowNodeFinished', () => {
},
})
act(() => {
result.current.handleWorkflowNodeFinished({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as NodeFinishedResponse)
})
result.current.handleWorkflowNodeFinished({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as NodeFinishedResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should set _runningBranchId for IfElse node', async () => {
const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
it('should set _runningBranchId for IfElse node', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@ -181,75 +116,83 @@ describe('useWorkflowNodeFinished', () => {
},
})
act(() => {
result.current.handleWorkflowNodeFinished({
data: {
id: 'trace-1',
node_id: 'n1',
node_type: 'if-else',
status: NodeRunningStatus.Succeeded,
outputs: { selected_case_id: 'branch-a' },
},
} as unknown as NodeFinishedResponse)
})
result.current.handleWorkflowNodeFinished({
data: {
id: 'trace-1',
node_id: 'n1',
node_type: 'if-else',
status: NodeRunningStatus.Succeeded,
outputs: { selected_case_id: 'branch-a' },
},
} as unknown as NodeFinishedResponse)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningBranchId).toBe('branch-a')
})
})
describe('useWorkflowNodeRetry', () => {
it('should push retry data to tracing and update _retryIndex', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should push retry data to tracing and update _retryIndex', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
})
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._retryIndex).toBe(2)
})
})
describe('useWorkflowNodeIterationNext', () => {
it('should set _iterationIndex and increment iterTimes', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should set _iterationIndex and increment iterTimes', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
act(() => {
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
})
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._iterationIndex).toBe(3)
expect(store.getState().iterTimes).toBe(4)
})
})
describe('useWorkflowNodeIterationFinished', () => {
it('should update tracing, reset iterTimes, update node status and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, reset iterTimes, update node status and edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@ -258,60 +201,56 @@ describe('useWorkflowNodeIterationFinished', () => {
},
})
act(() => {
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
})
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeLoopNext', () => {
it('should set _loopIndex and reset child nodes to waiting', async () => {
const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
nodes: [
createNode({ id: 'n1', data: {} }),
createNode({
id: 'n2',
position: { x: 300, y: 0 },
parentId: 'n1',
data: { _waitingRun: false },
}),
],
edges: [],
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } },
]
})
it('should set _loopIndex and reset child nodes to waiting', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
})
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._loopIndex).toBe(5)
expect(updatedNodes[1].data._waitingRun).toBe(true)
expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting)
})
})
describe('useWorkflowNodeLoopFinished', () => {
it('should update tracing, node status and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, node status and edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@ -319,18 +258,12 @@ describe('useWorkflowNodeLoopFinished', () => {
},
})
act(() => {
result.current.handleWorkflowNodeLoopFinished({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as LoopFinishedResponse)
})
result.current.handleWorkflowNodeLoopFinished({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as LoopFinishedResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})

View File

@ -4,10 +4,8 @@ import type {
LoopStartedResponse,
NodeStartedResponse,
} from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus } from '../../types'
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
@ -15,145 +13,67 @@ import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-w
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
type NodeRuntimeState = {
_waitingRun?: boolean
_runningStatus?: NodeRunningStatus
_iterationLength?: number
_loopLength?: number
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
function findNodeById(nodes: Array<{ id: string, data: Record<string, unknown> }>, id: string) {
return nodes.find(n => n.id === id)!
}
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const containerParams = { clientWidth: 1200, clientHeight: 800 }
function createViewportNodes() {
return [
createNode({
id: 'n0',
width: 200,
height: 80,
data: { _runningStatus: NodeRunningStatus.Succeeded },
}),
createNode({
id: 'n1',
position: { x: 100, y: 50 },
width: 200,
height: 80,
data: { _waitingRun: true },
}),
createNode({
id: 'n2',
position: { x: 400, y: 50 },
width: 200,
height: 80,
parentId: 'n1',
data: { _waitingRun: true },
}),
]
}
function createViewportEdges() {
return [
createEdge({
id: 'e1',
source: 'n0',
target: 'n1',
sourceHandle: 'source',
data: {},
}),
]
}
function renderViewportHook<T extends Record<string, unknown>>(
useHook: () => T,
options?: {
nodes?: ReturnType<typeof createViewportNodes>
edges?: ReturnType<typeof createViewportEdges>
initialStoreState?: Record<string, unknown>
},
) {
const {
nodes = createViewportNodes(),
edges = createViewportEdges(),
initialStoreState,
} = options ?? {}
return renderWorkflowFlowHook(() => ({
...useHook(),
nodes: useNodes(),
edges: useEdges(),
reactFlowStore: useStoreApi(),
}), {
nodes,
edges,
reactFlowProps: { fitView: false },
initialStoreState,
})
}
describe('useWorkflowNodeStarted', () => {
it('should push to tracing, set node running, and adjust viewport for root node', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
{ id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set node running, and adjust viewport for root node', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(1)
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
it('should not adjust viewport for child node (has parentId)', async () => {
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
it('should not adjust viewport for child node (has parentId)', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(0)
expect(transform[1]).toBe(0)
expect(transform[2]).toBe(1)
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
})
expect(rfState.setViewport).not.toHaveBeenCalled()
})
it('should update existing tracing entry if node_id exists at non-zero index', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
@ -164,12 +84,10 @@ describe('useWorkflowNodeStarted', () => {
},
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
@ -178,80 +96,92 @@ describe('useWorkflowNodeStarted', () => {
})
describe('useWorkflowNodeIterationStarted', () => {
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
nodes: createViewportNodes().slice(0, 2),
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 99,
},
})
act(() => {
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
})
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
expect(rfState.setViewport).toHaveBeenCalledOnce()
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._iterationLength).toBe(10)
expect(n1.data._waitingRun).toBe(false)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeLoopStarted', () => {
it('should push to tracing, set viewport, and update node with _loopLength', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
nodes: createViewportNodes().slice(0, 2),
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set viewport, and update node with _loopLength', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
})
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
expect(rfState.setViewport).toHaveBeenCalledOnce()
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._loopLength).toBe(5)
expect(n1.data._waitingRun).toBe(false)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._loopLength).toBe(5)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
expect(rfState.setEdges).toHaveBeenCalledOnce()
})
})
describe('useWorkflowNodeHumanInputRequired', () => {
it('should create humanInputFormDataList and set tracing/node to Paused', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
{ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
})
it('should create humanInputFormDataList and set tracing/node to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
@ -259,29 +189,21 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(1)
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused)
})
it('should update existing form entry for same node_id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
@ -292,11 +214,9 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
expect(formList).toHaveLength(1)
@ -304,12 +224,7 @@ describe('useWorkflowNodeHumanInputRequired', () => {
})
it('should append new form entry for different node_id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
@ -320,11 +235,9 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})

View File

@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { baseRunningData, renderWorkflowFlowHook, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import {
useIsChatMode,
@ -10,6 +10,9 @@ import {
useWorkflowReadOnly,
} from '../use-workflow'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
let mockAppMode = 'workflow'
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }),
@ -17,6 +20,7 @@ vi.mock('@/app/components/app/store', () => ({
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockAppMode = 'workflow'
})
@ -154,50 +158,37 @@ describe('useNodesReadOnly', () => {
// ---------------------------------------------------------------------------
describe('useIsNodeInIteration', () => {
const createIterationNodes = () => [
createNode({ id: 'iter-1' }),
createNode({ id: 'child-1', parentId: 'iter-1' }),
createNode({ id: 'grandchild-1', parentId: 'child-1' }),
createNode({ id: 'outside-1' }),
]
beforeEach(() => {
rfState.nodes = [
{ id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } },
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} },
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
]
})
it('should return true when node is a direct child of the iteration', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('grandchild-1')).toBe(false)
})
it('should return false when node is outside the iteration', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
expect(result.current.isNodeInIteration('nonexistent')).toBe(false)
})
it('should return false when iteration id has no children', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('no-such-iter'), {
nodes: createIterationNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInIteration('no-such-iter'))
expect(result.current.isNodeInIteration('child-1')).toBe(false)
})
})
@ -207,50 +198,37 @@ describe('useIsNodeInIteration', () => {
// ---------------------------------------------------------------------------
describe('useIsNodeInLoop', () => {
const createLoopNodes = () => [
createNode({ id: 'loop-1' }),
createNode({ id: 'child-1', parentId: 'loop-1' }),
createNode({ id: 'grandchild-1', parentId: 'child-1' }),
createNode({ id: 'outside-1' }),
]
beforeEach(() => {
rfState.nodes = [
{ id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } },
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} },
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
]
})
it('should return true when node is a direct child of the loop', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('grandchild-1')).toBe(false)
})
it('should return false when node is outside the loop', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
expect(result.current.isNodeInLoop('nonexistent')).toBe(false)
})
it('should return false when loop id has no children', () => {
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('no-such-loop'), {
nodes: createLoopNodes(),
edges: [],
})
const { result } = renderHook(() => useIsNodeInLoop('no-such-loop'))
expect(result.current.isNodeInLoop('child-1')).toBe(false)
})
})

View File

@ -6,18 +6,16 @@ import type {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { createDocLinkMock } from '../../../../__tests__/i18n'
import { AgentStrategy } from '../agent-strategy'
const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text })
const mockDocLink = createDocLinkMock('/docs')
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({ data: null }),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
useDocLink: () => () => '/docs',
}))
vi.mock('@/hooks/use-i18n', () => ({

View File

@ -1,83 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Collapse from '../index'
describe('Collapse', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Collapse should toggle local state when interactive and stay fixed when disabled.
describe('Interaction', () => {
it('should expand collapsed content and notify onCollapse when clicked', async () => {
const user = userEvent.setup()
const onCollapse = vi.fn()
render(
<Collapse
trigger={<div>Advanced</div>}
onCollapse={onCollapse}
>
<div>Collapse content</div>
</Collapse>,
)
expect(screen.queryByText('Collapse content')).not.toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.getByText('Collapse content')).toBeInTheDocument()
expect(onCollapse).toHaveBeenCalledWith(false)
})
it('should keep content collapsed when disabled', async () => {
const user = userEvent.setup()
const onCollapse = vi.fn()
render(
<Collapse
disabled
trigger={<div>Disabled section</div>}
onCollapse={onCollapse}
>
<div>Hidden content</div>
</Collapse>,
)
await user.click(screen.getByText('Disabled section'))
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
expect(onCollapse).not.toHaveBeenCalled()
})
it('should respect controlled collapse state and render function triggers', async () => {
const user = userEvent.setup()
const onCollapse = vi.fn()
render(
<Collapse
collapsed={false}
hideCollapseIcon
operations={<button type="button">Operation</button>}
trigger={collapseIcon => (
<div>
<span>Controlled section</span>
{collapseIcon}
</div>
)}
onCollapse={onCollapse}
>
<div>Visible content</div>
</Collapse>,
)
expect(screen.getByText('Visible content')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Operation' })).toBeInTheDocument()
await user.click(screen.getByText('Controlled section'))
expect(onCollapse).toHaveBeenCalledWith(true)
expect(screen.getByText('Visible content')).toBeInTheDocument()
})
})
})

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Field from '../field'
import Field from './field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,

View File

@ -1,18 +0,0 @@
import { render, screen } from '@testing-library/react'
import InputField from '../index'
describe('InputField', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The placeholder field should render its title, body, and add action.
describe('Rendering', () => {
it('should render the default field title and content', () => {
render(<InputField />)
expect(screen.getAllByText('input field')).toHaveLength(2)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react'
import { BoxGroupField, FieldTitle } from '../index'
describe('layout index', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The barrel exports should compose the public layout primitives without extra wrappers.
describe('Rendering', () => {
it('should render BoxGroupField from the barrel export', () => {
render(
<BoxGroupField
fieldProps={{
fieldTitleProps: {
title: 'Input',
},
}}
>
Body content
</BoxGroupField>,
)
expect(screen.getByText('Input')).toBeInTheDocument()
expect(screen.getByText('Body content')).toBeInTheDocument()
})
it('should render FieldTitle from the barrel export', () => {
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
expect(screen.getByText('Advanced')).toBeInTheDocument()
expect(screen.getByText('Extra details')).toBeInTheDocument()
})
})
})

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { FieldTitle } from '../field-title'
import { FieldTitle } from './field-title'
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@ -1,195 +0,0 @@
import type { ReactNode } from 'react'
import type { Edge, Node } from '@/app/components/workflow/types'
import { screen } from '@testing-library/react'
import {
createEdge,
createNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useNodesInteractions,
useNodesReadOnly,
useToolIcon,
} from '@/app/components/workflow/hooks'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { BlockEnum } from '@/app/components/workflow/types'
import NextStep from '../index'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger }: { trigger: ((open: boolean) => ReactNode) | ReactNode }) => {
return (
<div data-testid="next-step-block-selector">
{typeof trigger === 'function' ? trigger(false) : trigger}
</div>
)
},
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useToolIcon: vi.fn(),
}
})
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseToolIcon = vi.mocked(useToolIcon)
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [],
})),
availablePrevBlocks: [],
availableNextBlocks: [],
})
const renderComponent = (selectedNode: Node, nodes: Node[], edges: Edge[] = []) =>
renderWorkflowFlowComponent(
<NextStep selectedNode={selectedNode} />,
{
nodes,
edges,
canvasStyle: {
width: 600,
height: 400,
},
},
)
describe('NextStep', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
mockUseNodesInteractions.mockReturnValue({
handleNodeSelect: vi.fn(),
handleNodeAdd: vi.fn(),
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({
nodesReadOnly: true,
} as ReturnType<typeof useNodesReadOnly>)
mockUseToolIcon.mockReturnValue('')
})
// NextStep should summarize linear next nodes and failure branches from the real ReactFlow graph.
describe('Rendering', () => {
it('should render connected next nodes and the parallel add action for the default source handle', () => {
const selectedNode = createNode({
id: 'selected-node',
data: {
type: BlockEnum.Code,
title: 'Selected Node',
},
})
const nextNode = createNode({
id: 'next-node',
data: {
type: BlockEnum.Answer,
title: 'Next Node',
},
})
const edge = createEdge({
source: 'selected-node',
target: 'next-node',
sourceHandle: 'source',
})
renderComponent(selectedNode, [selectedNode, nextNode], [edge])
expect(screen.getByText('Next Node')).toBeInTheDocument()
expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument()
})
it('should render configured branch names when target branches are present', () => {
const selectedNode = createNode({
id: 'selected-node',
data: {
type: BlockEnum.Code,
title: 'Selected Node',
_targetBranches: [{
id: 'branch-a',
name: 'Approved',
}],
},
})
const nextNode = createNode({
id: 'next-node',
data: {
type: BlockEnum.Answer,
title: 'Branch Node',
},
})
const edge = createEdge({
source: 'selected-node',
target: 'next-node',
sourceHandle: 'branch-a',
})
renderComponent(selectedNode, [selectedNode, nextNode], [edge])
expect(screen.getByText('Approved')).toBeInTheDocument()
expect(screen.getByText('Branch Node')).toBeInTheDocument()
expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument()
})
it('should number question-classifier branches even when no target node is connected', () => {
const selectedNode = createNode({
id: 'selected-node',
data: {
type: BlockEnum.QuestionClassifier,
title: 'Classifier',
_targetBranches: [{
id: 'branch-b',
name: 'Original branch name',
}],
},
})
const danglingEdge = createEdge({
source: 'selected-node',
target: 'missing-node',
sourceHandle: 'branch-b',
})
renderComponent(selectedNode, [selectedNode], [danglingEdge])
expect(screen.getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument()
expect(screen.getByText('workflow.panel.selectNextStep')).toBeInTheDocument()
})
it('should render the failure branch when the node has error handling enabled', () => {
const selectedNode = createNode({
id: 'selected-node',
data: {
type: BlockEnum.Code,
title: 'Selected Node',
error_strategy: ErrorHandleTypeEnum.failBranch,
},
})
const failNode = createNode({
id: 'fail-node',
data: {
type: BlockEnum.Answer,
title: 'Failure Node',
},
})
const failEdge = createEdge({
source: 'selected-node',
target: 'fail-node',
sourceHandle: ErrorHandleTypeEnum.failBranch,
})
renderComponent(selectedNode, [selectedNode, failNode], [failEdge])
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
expect(screen.getByText('Failure Node')).toBeInTheDocument()
expect(screen.getByText('workflow.common.addFailureBranch')).toBeInTheDocument()
})
})
})

View File

@ -1,8 +1,8 @@
import type { CommonNodeType } from '../../../../types'
import type { CommonNodeType } from '../../../types'
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '../../../../types'
import NodeControl from '../node-control'
import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import NodeControl from './node-control'
const {
mockHandleNodeSelect,
@ -14,8 +14,8 @@ const {
let mockPluginInstallLocked = false
vi.mock('../../../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../../../hooks')>('../../../../hooks')
vi.mock('../../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../../hooks')>('../../../hooks')
return {
...actual,
useNodesInteractions: () => ({
@ -24,15 +24,15 @@ vi.mock('../../../../hooks', async () => {
}
})
vi.mock('../../../../utils', async () => {
const actual = await vi.importActual<typeof import('../../../../utils')>('../../../../utils')
vi.mock('../../../utils', async () => {
const actual = await vi.importActual<typeof import('../../../utils')>('../../../utils')
return {
...actual,
canRunBySingle: mockCanRunBySingle,
}
})
vi.mock('../panel-operator', () => ({
vi.mock('./panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
<>
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>

View File

@ -1,162 +0,0 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import PanelOperator from '../index'
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
vi.mock('../change-block', () => ({
default: () => <div data-testid="panel-operator-change-block" />,
}))
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
data,
error: null,
refetch: vi.fn(),
isError: false,
isPending: false,
isLoading: false,
isSuccess: true,
isFetching: false,
isRefetching: false,
isLoadingError: false,
isRefetchError: false,
isInitialLoading: false,
isPaused: false,
isEnabled: true,
status: 'success',
fetchStatus: 'idle',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isPlaceholderData: false,
isStale: false,
promise: Promise.resolve(data),
} as UseQueryResult<T, Error>)
const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) =>
renderWorkflowFlowComponent(
<PanelOperator
id="node-1"
data={{
title: 'Code Node',
desc: '',
type: BlockEnum.Code,
}}
triggerClassName="panel-operator-trigger"
onOpenChange={onOpenChange}
showHelpLink={showHelpLink}
/>,
{
nodes: [],
edges: [],
},
)
describe('PanelOperator', () => {
const handleNodeSelect = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
const handleNodeDelete = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft: vi.fn(),
})
mockUseNodeMetaData.mockReturnValue({
isTypeFixed: false,
isSingleton: false,
isUndeletable: false,
description: 'Node description',
author: 'Dify',
helpLinkUri: 'https://docs.example.com/node',
} as ReturnType<typeof useNodeMetaData>)
mockUseNodesInteractions.mockReturnValue({
handleNodeDelete,
handleNodesDuplicate: vi.fn(),
handleNodeSelect,
handleNodesCopy: vi.fn(),
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({
nodesReadOnly: false,
} as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
})
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
})
// The operator should open the real popup, expose actionable items, and respect help-link visibility.
describe('Popup Interaction', () => {
it('should open the popup and trigger single-run actions', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
const { container } = renderComponent(true, onOpenChange)
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
expect(onOpenChange).toHaveBeenCalledWith(true)
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
await user.click(screen.getByText('workflow.panel.runThisStep'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
id: 'node-1',
data: { _isSingleRun: true },
})
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should hide the help link when showHelpLink is false', async () => {
const user = userEvent.setup()
const { container } = renderComponent(false)
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
})
})
})

View File

@ -1,4 +1,4 @@
import matchTheSchemaType from '../match-schema-type'
import matchTheSchemaType from './match-schema-type'
describe('match the schema type', () => {
it('should return true for identical primitive types', () => {

View File

@ -1,43 +0,0 @@
import { render, screen } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VariableLabelInNode, VariableLabelInText } from '../index'
describe('variable-label index', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The barrel exports should render the node and text variants with the expected variable metadata.
describe('Rendering', () => {
it('should render the node variant with node label and variable type', () => {
render(
<VariableLabelInNode
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'answer']}
variableType={VarType.string}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should render the text variant with the shortened variable path', () => {
render(
<VariableLabelInText
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload', 'answer']}
notShowFullPath
isExceptionVariable
/>,
)
expect(screen.getByTestId('exception-variable')).toBeInTheDocument()
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
})
})
})

View File

@ -1,67 +0,0 @@
import type { AnswerNodeType } from '../types'
import { screen } from '@testing-library/react'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { useWorkflow } from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useWorkflow: vi.fn(),
}
})
const mockUseWorkflow = vi.mocked(useWorkflow)
const createNodeData = (overrides: Partial<AnswerNodeType> = {}): AnswerNodeType => ({
title: 'Answer',
desc: '',
type: BlockEnum.Answer,
variables: [],
answer: 'Plain answer',
...overrides,
})
describe('AnswerNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranchIncludeParent: () => [],
} as unknown as ReturnType<typeof useWorkflow>)
})
// The node should render the localized panel title and plain answer text.
describe('Rendering', () => {
it('should render the answer title and text content', () => {
renderNodeComponent(Node, createNodeData())
expect(screen.getByText('workflow.nodes.answer.answer')).toBeInTheDocument()
expect(screen.getByText('Plain answer')).toBeInTheDocument()
})
it('should render referenced variables inside the readonly content', () => {
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranchIncludeParent: () => [
createNode({
id: 'source-node',
data: {
type: BlockEnum.Code,
title: 'Source Node',
},
}),
],
} as unknown as ReturnType<typeof useWorkflow>)
renderNodeComponent(Node, createNodeData({
answer: 'Hello {{#source-node.name#}}',
}))
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
})
})
})

View File

@ -1,6 +1,6 @@
import { VarType } from '../../../types'
import { extractFunctionParams, extractReturnType } from '../code-parser'
import { CodeLanguage } from '../types'
import { VarType } from '../../types'
import { extractFunctionParams, extractReturnType } from './code-parser'
import { CodeLanguage } from './types'
const SAMPLE_CODES = {
python3: {

View File

@ -1,101 +0,0 @@
import type { ComponentProps, ReactNode } from 'react'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import DataSourceEmptyNode from '../index'
const mockUseReplaceDataSourceNode = vi.hoisted(() => vi.fn())
vi.mock('../hooks', () => ({
useReplaceDataSourceNode: mockUseReplaceDataSourceNode,
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({
onSelect,
trigger,
}: {
onSelect: OnSelectBlock
trigger: ((open?: boolean) => ReactNode) | ReactNode
}) => (
<div>
{typeof trigger === 'function' ? trigger(false) : trigger}
<button
type="button"
onClick={() => onSelect(BlockEnum.DataSource, {
plugin_id: 'plugin-id',
provider_type: 'datasource',
provider_name: 'file',
datasource_name: 'local-file',
datasource_label: 'Local File',
title: 'Local File',
})}
>
select data source
</button>
</div>
),
}))
type DataSourceEmptyNodeProps = ComponentProps<typeof DataSourceEmptyNode>
const createNodeProps = (): DataSourceEmptyNodeProps => ({
id: 'data-source-empty-node',
data: {
width: 240,
height: 88,
},
type: 'default',
selected: false,
zIndex: 0,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
dragHandle: undefined,
} as unknown as DataSourceEmptyNodeProps)
describe('DataSourceEmptyNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseReplaceDataSourceNode.mockReturnValue({
handleReplaceNode: vi.fn(),
})
})
// The empty datasource node should render the add trigger and forward selector choices.
describe('Rendering and Selection', () => {
it('should render the datasource add trigger', () => {
render(
<DataSourceEmptyNode {...createNodeProps()} />,
)
expect(screen.getByText('workflow.nodes.dataSource.add')).toBeInTheDocument()
expect(screen.getByText('workflow.blocks.datasource')).toBeInTheDocument()
})
it('should forward block selections to the replace hook', async () => {
const user = userEvent.setup()
const handleReplaceNode = vi.fn()
mockUseReplaceDataSourceNode.mockReturnValue({
handleReplaceNode,
})
render(
<DataSourceEmptyNode {...createNodeProps()} />,
)
await user.click(screen.getByRole('button', { name: 'select data source' }))
expect(handleReplaceNode).toHaveBeenCalledWith(BlockEnum.DataSource, {
plugin_id: 'plugin-id',
provider_type: 'datasource',
provider_name: 'file',
datasource_name: 'local-file',
datasource_label: 'Local File',
title: 'Local File',
})
})
})
})

View File

@ -1,76 +0,0 @@
import type { DataSourceNodeType } from '../types'
import { render, screen } from '@testing-library/react'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
const mockInstallPluginButton = vi.hoisted(() => vi.fn(({ uniqueIdentifier }: { uniqueIdentifier: string }) => (
<button type="button">{uniqueIdentifier}</button>
)))
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
useNodePluginInstallation: vi.fn(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: mockInstallPluginButton,
}))
const mockUseNodePluginInstallation = vi.mocked(useNodePluginInstallation)
const createNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
title: 'Datasource',
desc: '',
type: BlockEnum.DataSource,
plugin_id: 'plugin-id',
provider_type: 'datasource',
provider_name: 'file',
datasource_name: 'local-file',
datasource_label: 'Local File',
datasource_parameters: {},
datasource_configurations: {},
plugin_unique_identifier: 'plugin-id@1.0.0',
...overrides,
})
describe('DataSourceNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNodePluginInstallation.mockReturnValue({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: vi.fn(),
shouldDim: false,
})
})
// The node should only expose install affordances when the backing plugin is missing and installable.
describe('Plugin Installation', () => {
it('should render the install button when the datasource plugin is missing', () => {
mockUseNodePluginInstallation.mockReturnValue({
isChecking: false,
isMissing: true,
uniqueIdentifier: 'plugin-id@1.0.0',
canInstall: true,
onInstallSuccess: vi.fn(),
shouldDim: true,
})
render(<Node id="data-source-node" data={createNodeData()} />)
expect(screen.getByRole('button', { name: 'plugin-id@1.0.0' })).toBeInTheDocument()
expect(mockInstallPluginButton).toHaveBeenCalledWith(expect.objectContaining({
uniqueIdentifier: 'plugin-id@1.0.0',
extraIdentifiers: ['plugin-id', 'file'],
}), undefined)
})
it('should render nothing when installation is unavailable', () => {
const { container } = render(<Node id="data-source-node" data={createNodeData()} />)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -1,93 +0,0 @@
import type { EndNodeType } from '../types'
import { screen } from '@testing-library/react'
import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useIsChatMode,
useWorkflow,
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useWorkflow: vi.fn(),
useWorkflowVariables: vi.fn(),
useIsChatMode: vi.fn(),
}
})
const mockUseWorkflow = vi.mocked(useWorkflow)
const mockUseWorkflowVariables = vi.mocked(useWorkflowVariables)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const createNodeData = (overrides: Partial<EndNodeType> = {}): EndNodeType => ({
title: 'End',
desc: '',
type: BlockEnum.End,
outputs: [{
variable: 'answer',
value_selector: ['source-node', 'answer'],
}],
...overrides,
})
describe('EndNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranch: () => [
createStartNode(),
createNode({
id: 'source-node',
data: {
type: BlockEnum.Code,
title: 'Source Node',
},
}),
],
} as unknown as ReturnType<typeof useWorkflow>)
mockUseWorkflowVariables.mockReturnValue({
getNodeAvailableVars: () => [],
getCurrentVariableType: () => 'string',
} as unknown as ReturnType<typeof useWorkflowVariables>)
mockUseIsChatMode.mockReturnValue(false)
})
// The node should surface only resolved outputs and ignore empty selectors.
describe('Rendering', () => {
it('should render resolved output labels for referenced nodes', () => {
renderNodeComponent(Node, createNodeData())
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should fall back to the start node when the selector node cannot be found', () => {
renderNodeComponent(Node, createNodeData({
outputs: [{
variable: 'answer',
value_selector: ['missing-node', 'answer'],
}],
}))
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
})
it('should render nothing when every output selector is empty', () => {
const { container } = renderNodeComponent(Node, createNodeData({
outputs: [{
variable: 'answer',
value_selector: [],
}],
}))
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -1,94 +0,0 @@
import type { NodeProps } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { render, waitFor } from '@testing-library/react'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useIsChatMode,
useNodesInteractions,
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import IterationStartNode, { IterationStartNodeDumb } from '../index'
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useIsChatMode: vi.fn(),
}
})
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [],
})),
availablePrevBlocks: [],
availableNextBlocks: [],
})
const FlowNode = (props: NodeProps<CommonNodeType>) => (
<IterationStartNode {...props} />
)
const renderFlowNode = () =>
renderWorkflowFlowComponent(<div />, {
nodes: [createNode({
id: 'iteration-start-node',
type: 'iterationStartNode',
data: {
title: 'Iteration Start',
desc: '',
type: BlockEnum.IterationStart,
},
})],
edges: [],
reactFlowProps: {
nodeTypes: { iterationStartNode: FlowNode },
},
canvasStyle: {
width: 400,
height: 300,
},
})
describe('IterationStartNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
mockUseNodesInteractions.mockReturnValue({
handleNodeAdd: vi.fn(),
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({
getNodesReadOnly: () => false,
} as unknown as ReturnType<typeof useNodesReadOnly>)
mockUseIsChatMode.mockReturnValue(false)
})
// The start marker should provide the source handle in flow mode and omit it in dumb mode.
describe('Rendering', () => {
it('should render the source handle in the ReactFlow context', async () => {
const { container } = renderFlowNode()
await waitFor(() => {
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
})
})
it('should render the dumb variant without any source handle', () => {
const { container } = render(<IterationStartNodeDumb />)
expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument()
})
})
})

View File

@ -1,93 +0,0 @@
import type { KnowledgeBaseNodeType } from '../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
import useSingleRunFormParams from '../use-single-run-form-params'
const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({
title: 'Knowledge Base',
desc: '',
type: BlockEnum.KnowledgeBase,
index_chunk_variable_selector: ['chunks', 'results'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
keyword_number: 10,
retrieval_model: {
search_method: RetrievalSearchMethodEnum.semantic,
reranking_enable: false,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
...overrides,
})
describe('useSingleRunFormParams', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The hook should expose the single query form and map chunk dependencies for single-run execution.
describe('Forms', () => {
it('should build the query form with the current run input value', () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'knowledge-base-1',
payload: createPayload(),
runInputData: { query: 'what is dify' },
getInputVars: vi.fn(),
setRunInputData: vi.fn(),
toVarInputs: vi.fn(),
}))
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0].inputs).toEqual([{
label: 'workflow.nodes.common.inputVars',
variable: 'query',
type: InputVarType.paragraph,
required: true,
}])
expect(result.current.forms[0].values).toEqual({ query: 'what is dify' })
})
it('should update run input data when the query changes', () => {
const setRunInputData = vi.fn()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'knowledge-base-1',
payload: createPayload(),
runInputData: { query: 'old query' },
getInputVars: vi.fn(),
setRunInputData,
toVarInputs: vi.fn(),
}))
act(() => {
result.current.forms[0].onChange({ query: 'new query' })
})
expect(setRunInputData).toHaveBeenCalledWith({ query: 'new query' })
})
})
describe('Dependencies', () => {
it('should expose the chunk selector as the only dependent variable', () => {
const payload = createPayload({
index_chunk_variable_selector: ['node-1', 'chunks'],
})
const { result } = renderHook(() => useSingleRunFormParams({
id: 'knowledge-base-1',
payload,
runInputData: {},
getInputVars: vi.fn(),
setRunInputData: vi.fn(),
toVarInputs: vi.fn(),
}))
expect(result.current.getDependentVars()).toEqual([['node-1', 'chunks']])
expect(result.current.getDependentVar('query')).toEqual(['node-1', 'chunks'])
expect(result.current.getDependentVar('other')).toBeUndefined()
})
})
})

View File

@ -1,74 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { ChunkStructureEnum, IndexMethodEnum } from '../../types'
import IndexMethod from '../index-method'
describe('IndexMethod', () => {
it('should render both index method options for general chunks and notify option changes', () => {
const onIndexMethodChange = vi.fn()
render(
<IndexMethod
chunkStructure={ChunkStructureEnum.general}
indexMethod={IndexMethodEnum.QUALIFIED}
keywordNumber={5}
onIndexMethodChange={onIndexMethodChange}
onKeywordNumberChange={vi.fn()}
/>,
)
expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument()
expect(screen.getByText('datasetSettings.form.indexMethodEconomy')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument()
fireEvent.click(screen.getByText('datasetSettings.form.indexMethodEconomy'))
expect(onIndexMethodChange).toHaveBeenCalledWith(IndexMethodEnum.ECONOMICAL)
})
it('should update the keyword number when the economical option is active', () => {
const onKeywordNumberChange = vi.fn()
const { container } = render(
<IndexMethod
chunkStructure={ChunkStructureEnum.general}
indexMethod={IndexMethodEnum.ECONOMICAL}
keywordNumber={5}
onIndexMethodChange={vi.fn()}
onKeywordNumberChange={onKeywordNumberChange}
/>,
)
fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } })
expect(onKeywordNumberChange).toHaveBeenCalledWith(7)
})
it('should disable keyword controls when readonly is enabled', () => {
const { container } = render(
<IndexMethod
chunkStructure={ChunkStructureEnum.general}
indexMethod={IndexMethodEnum.ECONOMICAL}
keywordNumber={5}
onIndexMethodChange={vi.fn()}
onKeywordNumberChange={vi.fn()}
readonly
/>,
)
expect(container.querySelector('input')).toBeDisabled()
})
it('should hide the economical option for non-general chunk structures', () => {
render(
<IndexMethod
chunkStructure={ChunkStructureEnum.parent_child}
indexMethod={IndexMethodEnum.QUALIFIED}
keywordNumber={5}
onIndexMethodChange={vi.fn()}
onKeywordNumberChange={vi.fn()}
/>,
)
expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument()
expect(screen.queryByText('datasetSettings.form.indexMethodEconomy')).not.toBeInTheDocument()
})
})

View File

@ -1,74 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import OptionCard from '../option-card'
describe('OptionCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The card should expose selection, child expansion, and readonly click behavior.
describe('Interaction', () => {
it('should call onClick with the card id and render active children', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(
<OptionCard
id="qualified"
selectedId="qualified"
title="High Quality"
description="Use embedding retrieval."
isRecommended
enableRadio
onClick={onClick}
>
<div>Advanced controls</div>
</OptionCard>,
)
expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument()
expect(screen.getByText('Advanced controls')).toBeInTheDocument()
await user.click(screen.getByText('High Quality'))
expect(onClick).toHaveBeenCalledWith('qualified')
})
it('should not trigger selection when the card is readonly', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(
<OptionCard
id="economical"
title="Economical"
readonly
onClick={onClick}
/>,
)
await user.click(screen.getByText('Economical'))
expect(onClick).not.toHaveBeenCalled()
})
it('should support function-based wrapper, class, and icon props without enabling selection', () => {
render(
<OptionCard
id="inactive"
selectedId="qualified"
title="Inactive card"
enableSelect={false}
wrapperClassName={isActive => (isActive ? 'wrapper-active' : 'wrapper-inactive')}
className={isActive => (isActive ? 'body-active' : 'body-inactive')}
icon={isActive => <span data-testid="option-icon">{isActive ? 'active' : 'inactive'}</span>}
/>,
)
expect(screen.getByText('Inactive card').closest('.wrapper-inactive')).toBeInTheDocument()
expect(screen.getByTestId('option-icon')).toHaveTextContent('inactive')
expect(screen.getByText('Inactive card').closest('.body-inactive')).toBeInTheDocument()
})
})
})

View File

@ -1,47 +0,0 @@
import { render, renderHook } from '@testing-library/react'
import { ChunkStructureEnum } from '../../../types'
import { useChunkStructure } from '../hooks'
const renderIcon = (icon: ReturnType<typeof useChunkStructure>['options'][number]['icon'], isActive: boolean) => {
if (typeof icon !== 'function')
throw new Error('expected icon renderer')
return icon(isActive)
}
describe('useChunkStructure', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The hook should expose ordered options and a lookup map for every chunk structure variant.
describe('Options', () => {
it('should return all chunk structure options and map them by id', () => {
const { result } = renderHook(() => useChunkStructure())
expect(result.current.options).toHaveLength(3)
expect(result.current.options.map(option => option.id)).toEqual([
ChunkStructureEnum.general,
ChunkStructureEnum.parent_child,
ChunkStructureEnum.question_answer,
])
expect(result.current.optionMap[ChunkStructureEnum.general].title).toBe('datasetCreation.stepTwo.general')
expect(result.current.optionMap[ChunkStructureEnum.parent_child].title).toBe('datasetCreation.stepTwo.parentChild')
expect(result.current.optionMap[ChunkStructureEnum.question_answer].title).toBe('Q&A')
})
it('should expose active and inactive icon renderers for every option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalInactive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, false)}</>).container.firstChild as HTMLElement
const generalActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, true)}</>).container.firstChild as HTMLElement
const parentChildActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.parent_child].icon, true)}</>).container.firstChild as HTMLElement
const questionAnswerActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.question_answer].icon, true)}</>).container.firstChild as HTMLElement
expect(generalInactive).toHaveClass('text-text-tertiary')
expect(generalActive).toHaveClass('text-util-colors-indigo-indigo-600')
expect(parentChildActive).toHaveClass('text-util-colors-blue-light-blue-light-500')
expect(questionAnswerActive).toHaveClass('text-util-colors-teal-teal-600')
})
})
})

View File

@ -1,58 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { ChunkStructureEnum } from '../../../types'
import Selector from '../selector'
const options = [
{
id: ChunkStructureEnum.general,
icon: <span>G</span>,
title: 'General',
description: 'General description',
effectColor: 'blue',
},
{
id: ChunkStructureEnum.parent_child,
icon: <span>P</span>,
title: 'Parent child',
description: 'Parent child description',
effectColor: 'purple',
},
]
describe('ChunkStructureSelector', () => {
it('should open the selector panel and close it after selecting an option', () => {
const onChange = vi.fn()
render(
<Selector
options={options}
value={ChunkStructureEnum.general}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.change' }))
expect(screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure')).toBeInTheDocument()
fireEvent.click(screen.getByText('Parent child'))
expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child)
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
})
it('should not open the selector when readonly is enabled', () => {
render(
<Selector
options={options}
onChange={vi.fn()}
readonly
trigger={<button type="button">custom-trigger</button>}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'custom-trigger' }))
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
})
})

Some files were not shown because too many files have changed in this diff Show More