mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 12:16:11 +08:00
Compare commits
1 Commits
focal-quok
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| c99e82a105 |
@ -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)
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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=(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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=(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"""
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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=[
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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] = []
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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(() => {
|
||||
|
||||
@ -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> } = {},
|
||||
|
||||
@ -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))
|
||||
}
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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?.({
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
340
web/app/components/workflow/edge-contextmenu.spec.tsx
Normal file
340
web/app/components/workflow/edge-contextmenu.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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}
|
||||
@ -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" />,
|
||||
}))
|
||||
|
||||
@ -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',
|
||||
@ -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) => {
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>,
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>,
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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: {
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
Reference in New Issue
Block a user