Merge branch 'main' into 3-23-lazy-load

This commit is contained in:
Stephen Zhou 2026-03-23 17:08:11 +08:00 committed by GitHub
commit d44f1d63b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 13645 additions and 842 deletions

View File

@ -66,8 +66,8 @@ class HumanInputContent(ExecutionExtraContent):
form_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
@classmethod
def new(cls, form_id: str, message_id: str | None) -> "HumanInputContent":
return cls(form_id=form_id, message_id=message_id)
def new(cls, *, workflow_run_id: str, form_id: str, message_id: str | None) -> "HumanInputContent":
return cls(workflow_run_id=workflow_run_id, form_id=form_id, message_id=message_id)
form: Mapped["HumanInputForm"] = relationship(
"HumanInputForm",

View File

@ -203,7 +203,7 @@ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"]
# Required by vector store clients
############################################################
vdb = [
"alibabacloud_gpdb20160503~=3.8.0",
"alibabacloud_gpdb20160503~=5.1.0",
"alibabacloud_tea_openapi~=0.4.3",
"chromadb==0.5.20",
"clickhouse-connect~=0.14.1",

View File

@ -1,27 +0,0 @@
from __future__ import annotations
from sqlalchemy.orm import sessionmaker
from extensions.ext_database import db
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
from tests.test_containers_integration_tests.helpers.execution_extra_content import (
create_human_input_message_fixture,
)
def test_get_by_message_ids_returns_human_input_content(db_session_with_containers):
fixture = create_human_input_message_fixture(db_session_with_containers)
repository = SQLAlchemyExecutionExtraContentRepository(
session_maker=sessionmaker(bind=db.engine, expire_on_commit=False)
)
results = repository.get_by_message_ids([fixture.message.id])
assert len(results) == 1
assert len(results[0]) == 1
content = results[0][0]
assert content.submitted is True
assert content.form_submission_data is not None
assert content.form_submission_data.action_id == fixture.action_id
assert content.form_submission_data.action_text == fixture.action_text
assert content.form_submission_data.rendered_content == fixture.form.rendered_content

View File

@ -0,0 +1,407 @@
"""Integration tests for SQLAlchemyExecutionExtraContentRepository using Testcontainers.
Part of #32454 — replaces the mock-based unit tests with real database interactions.
"""
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
from uuid import uuid4
import pytest
from sqlalchemy import Engine, delete, select
from sqlalchemy.orm import Session, sessionmaker
from dify_graph.nodes.human_input.entities import FormDefinition, UserAction
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import ConversationFromSource, InvokeFrom
from models.execution_extra_content import ExecutionExtraContent, HumanInputContent
from models.human_input import (
ConsoleRecipientPayload,
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
RecipientType,
)
from models.model import App, Conversation, Message
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
@dataclass
class _TestScope:
"""Per-test data scope used to isolate DB rows.
IDs are populated after flushing the base entities to the database.
"""
tenant_id: str = ""
app_id: str = ""
user_id: str = ""
def _cleanup_scope_data(session: Session, scope: _TestScope) -> None:
"""Remove test-created DB rows for a test scope."""
form_ids_subquery = select(HumanInputForm.id).where(
HumanInputForm.tenant_id == scope.tenant_id,
)
session.execute(delete(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids_subquery)))
session.execute(delete(HumanInputDelivery).where(HumanInputDelivery.form_id.in_(form_ids_subquery)))
session.execute(
delete(ExecutionExtraContent).where(
ExecutionExtraContent.workflow_run_id.in_(
select(HumanInputForm.workflow_run_id).where(HumanInputForm.tenant_id == scope.tenant_id)
)
)
)
session.execute(delete(HumanInputForm).where(HumanInputForm.tenant_id == scope.tenant_id))
session.execute(delete(Message).where(Message.app_id == scope.app_id))
session.execute(delete(Conversation).where(Conversation.app_id == scope.app_id))
session.execute(delete(App).where(App.id == scope.app_id))
session.execute(delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == scope.tenant_id))
session.execute(delete(Account).where(Account.id == scope.user_id))
session.execute(delete(Tenant).where(Tenant.id == scope.tenant_id))
session.commit()
def _seed_base_entities(session: Session, scope: _TestScope) -> None:
"""Create the base tenant, account, and app needed by tests."""
tenant = Tenant(name="Test Tenant")
session.add(tenant)
session.flush()
scope.tenant_id = tenant.id
account = Account(
name="Test Account",
email=f"test_{uuid4()}@example.com",
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
session.add(account)
session.flush()
scope.user_id = account.id
tenant_join = TenantAccountJoin(
tenant_id=scope.tenant_id,
account_id=scope.user_id,
role=TenantAccountRole.OWNER,
current=True,
)
session.add(tenant_join)
app = App(
tenant_id=scope.tenant_id,
name="Test App",
description="",
mode="chat",
icon_type="emoji",
icon="bot",
icon_background="#FFFFFF",
enable_site=False,
enable_api=True,
api_rpm=100,
api_rph=100,
is_demo=False,
is_public=False,
is_universal=False,
created_by=scope.user_id,
updated_by=scope.user_id,
)
session.add(app)
session.flush()
scope.app_id = app.id
def _create_conversation(session: Session, scope: _TestScope) -> Conversation:
conversation = Conversation(
app_id=scope.app_id,
mode="chat",
name="Test Conversation",
summary="",
introduction="",
system_instruction="",
status="normal",
invoke_from=InvokeFrom.EXPLORE,
from_source=ConversationFromSource.CONSOLE,
from_account_id=scope.user_id,
from_end_user_id=None,
)
conversation.inputs = {}
session.add(conversation)
session.flush()
return conversation
def _create_message(
session: Session,
scope: _TestScope,
conversation_id: str,
workflow_run_id: str,
) -> Message:
message = Message(
app_id=scope.app_id,
conversation_id=conversation_id,
inputs={},
query="test query",
message={"messages": []},
answer="test answer",
message_tokens=50,
message_unit_price=Decimal("0.001"),
answer_tokens=80,
answer_unit_price=Decimal("0.001"),
provider_response_latency=0.5,
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=scope.user_id,
workflow_run_id=workflow_run_id,
)
session.add(message)
session.flush()
return message
def _create_submitted_form(
session: Session,
scope: _TestScope,
*,
workflow_run_id: str,
action_id: str = "approve",
action_title: str = "Approve",
node_title: str = "Approval",
) -> HumanInputForm:
expiration_time = datetime.utcnow() + timedelta(days=1)
form_definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id=action_id, title=action_title)],
rendered_content="rendered",
expiration_time=expiration_time,
node_title=node_title,
display_in_ui=True,
)
form = HumanInputForm(
tenant_id=scope.tenant_id,
app_id=scope.app_id,
workflow_run_id=workflow_run_id,
node_id="node-id",
form_definition=form_definition.model_dump_json(),
rendered_content=f"Rendered {action_title}",
status=HumanInputFormStatus.SUBMITTED,
expiration_time=expiration_time,
selected_action_id=action_id,
)
session.add(form)
session.flush()
return form
def _create_waiting_form(
session: Session,
scope: _TestScope,
*,
workflow_run_id: str,
default_values: dict | None = None,
) -> HumanInputForm:
expiration_time = datetime.utcnow() + timedelta(days=1)
form_definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values=default_values or {"name": "John"},
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
tenant_id=scope.tenant_id,
app_id=scope.app_id,
workflow_run_id=workflow_run_id,
node_id="node-id",
form_definition=form_definition.model_dump_json(),
rendered_content="Rendered block",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
session.add(form)
session.flush()
return form
def _create_human_input_content(
session: Session,
*,
workflow_run_id: str,
message_id: str,
form_id: str,
) -> HumanInputContent:
content = HumanInputContent.new(
workflow_run_id=workflow_run_id,
message_id=message_id,
form_id=form_id,
)
session.add(content)
return content
def _create_recipient(
session: Session,
*,
form_id: str,
delivery_id: str,
recipient_type: RecipientType = RecipientType.CONSOLE,
access_token: str = "token-1",
) -> HumanInputFormRecipient:
payload = ConsoleRecipientPayload(account_id=None)
recipient = HumanInputFormRecipient(
form_id=form_id,
delivery_id=delivery_id,
recipient_type=recipient_type,
recipient_payload=payload.model_dump_json(),
access_token=access_token,
)
session.add(recipient)
return recipient
def _create_delivery(session: Session, *, form_id: str) -> HumanInputDelivery:
from dify_graph.nodes.human_input.enums import DeliveryMethodType
from models.human_input import ConsoleDeliveryPayload
delivery = HumanInputDelivery(
form_id=form_id,
delivery_method_type=DeliveryMethodType.WEBAPP,
channel_payload=ConsoleDeliveryPayload().model_dump_json(),
)
session.add(delivery)
session.flush()
return delivery
@pytest.fixture
def repository(db_session_with_containers: Session) -> SQLAlchemyExecutionExtraContentRepository:
"""Build a repository backed by the testcontainers database engine."""
engine = db_session_with_containers.get_bind()
assert isinstance(engine, Engine)
return SQLAlchemyExecutionExtraContentRepository(sessionmaker(bind=engine, expire_on_commit=False))
@pytest.fixture
def test_scope(db_session_with_containers: Session) -> Generator[_TestScope]:
"""Provide an isolated scope and clean related data after each test."""
scope = _TestScope()
_seed_base_entities(db_session_with_containers, scope)
db_session_with_containers.commit()
yield scope
_cleanup_scope_data(db_session_with_containers, scope)
class TestGetByMessageIds:
"""Tests for SQLAlchemyExecutionExtraContentRepository.get_by_message_ids."""
def test_groups_contents_by_message(
self,
db_session_with_containers: Session,
repository: SQLAlchemyExecutionExtraContentRepository,
test_scope: _TestScope,
) -> None:
"""Submitted forms are correctly mapped and grouped by message ID."""
workflow_run_id = str(uuid4())
conversation = _create_conversation(db_session_with_containers, test_scope)
msg1 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
msg2 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
form = _create_submitted_form(
db_session_with_containers,
test_scope,
workflow_run_id=workflow_run_id,
action_id="approve",
action_title="Approve",
)
_create_human_input_content(
db_session_with_containers,
workflow_run_id=workflow_run_id,
message_id=msg1.id,
form_id=form.id,
)
db_session_with_containers.commit()
result = repository.get_by_message_ids([msg1.id, msg2.id])
assert len(result) == 2
# msg1 has one submitted content
assert len(result[0]) == 1
content = result[0][0]
assert content.submitted is True
assert content.workflow_run_id == workflow_run_id
assert content.form_submission_data is not None
assert content.form_submission_data.action_id == "approve"
assert content.form_submission_data.action_text == "Approve"
assert content.form_submission_data.rendered_content == "Rendered Approve"
assert content.form_submission_data.node_id == "node-id"
assert content.form_submission_data.node_title == "Approval"
# msg2 has no content
assert result[1] == []
def test_returns_unsubmitted_form_definition(
self,
db_session_with_containers: Session,
repository: SQLAlchemyExecutionExtraContentRepository,
test_scope: _TestScope,
) -> None:
"""Waiting forms return full form_definition with resolved token and defaults."""
workflow_run_id = str(uuid4())
conversation = _create_conversation(db_session_with_containers, test_scope)
msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
form = _create_waiting_form(
db_session_with_containers,
test_scope,
workflow_run_id=workflow_run_id,
default_values={"name": "John"},
)
delivery = _create_delivery(db_session_with_containers, form_id=form.id)
_create_recipient(
db_session_with_containers,
form_id=form.id,
delivery_id=delivery.id,
access_token="token-1",
)
_create_human_input_content(
db_session_with_containers,
workflow_run_id=workflow_run_id,
message_id=msg.id,
form_id=form.id,
)
db_session_with_containers.commit()
result = repository.get_by_message_ids([msg.id])
assert len(result) == 1
assert len(result[0]) == 1
domain_content = result[0][0]
assert domain_content.submitted is False
assert domain_content.workflow_run_id == workflow_run_id
assert domain_content.form_definition is not None
form_def = domain_content.form_definition
assert form_def.form_id == form.id
assert form_def.node_id == "node-id"
assert form_def.node_title == "Approval"
assert form_def.form_content == "Rendered block"
assert form_def.display_in_ui is True
assert form_def.form_token == "token-1"
assert form_def.resolved_default_values == {"name": "John"}
assert form_def.expiration_time == int(form.expiration_time.timestamp())
def test_empty_message_ids_returns_empty_list(
self,
repository: SQLAlchemyExecutionExtraContentRepository,
) -> None:
"""Passing no message IDs returns an empty list without hitting the DB."""
result = repository.get_by_message_ids([])
assert result == []

View File

@ -1,180 +0,0 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from core.entities.execution_extra_content import HumanInputContent as HumanInputContentDomain
from core.entities.execution_extra_content import HumanInputFormSubmissionData
from dify_graph.nodes.human_input.entities import (
FormDefinition,
UserAction,
)
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
from models.execution_extra_content import HumanInputContent as HumanInputContentModel
from models.human_input import ConsoleRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
class _FakeScalarResult:
def __init__(self, values: Sequence[HumanInputContentModel]):
self._values = list(values)
def all(self) -> list[HumanInputContentModel]:
return list(self._values)
class _FakeSession:
def __init__(self, values: Sequence[Sequence[object]]):
self._values = list(values)
def scalars(self, _stmt):
if not self._values:
return _FakeScalarResult([])
return _FakeScalarResult(self._values.pop(0))
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
@dataclass
class _FakeSessionMaker:
session: _FakeSession
def __call__(self) -> _FakeSession:
return self.session
def _build_form(action_id: str, action_title: str, rendered_content: str) -> HumanInputForm:
expiration_time = datetime.now(UTC) + timedelta(days=1)
definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id=action_id, title=action_title)],
rendered_content="rendered",
expiration_time=expiration_time,
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
id=f"form-{action_id}",
tenant_id="tenant-id",
app_id="app-id",
workflow_run_id="workflow-run",
node_id="node-id",
form_definition=definition.model_dump_json(),
rendered_content=rendered_content,
status=HumanInputFormStatus.SUBMITTED,
expiration_time=expiration_time,
)
form.selected_action_id = action_id
return form
def _build_content(message_id: str, action_id: str, action_title: str) -> HumanInputContentModel:
form = _build_form(
action_id=action_id,
action_title=action_title,
rendered_content=f"Rendered {action_title}",
)
content = HumanInputContentModel(
id=f"content-{message_id}",
form_id=form.id,
message_id=message_id,
workflow_run_id=form.workflow_run_id,
)
content.form = form
return content
def test_get_by_message_ids_groups_contents_by_message() -> None:
message_ids = ["msg-1", "msg-2"]
contents = [_build_content("msg-1", "approve", "Approve")]
repository = SQLAlchemyExecutionExtraContentRepository(
session_maker=_FakeSessionMaker(session=_FakeSession(values=[contents, []]))
)
result = repository.get_by_message_ids(message_ids)
assert len(result) == 2
assert [content.model_dump(mode="json", exclude_none=True) for content in result[0]] == [
HumanInputContentDomain(
workflow_run_id="workflow-run",
submitted=True,
form_submission_data=HumanInputFormSubmissionData(
node_id="node-id",
node_title="Approval",
rendered_content="Rendered Approve",
action_id="approve",
action_text="Approve",
),
).model_dump(mode="json", exclude_none=True)
]
assert result[1] == []
def test_get_by_message_ids_returns_unsubmitted_form_definition() -> None:
expiration_time = datetime.now(UTC) + timedelta(days=1)
definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values={"name": "John"},
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
id="form-1",
tenant_id="tenant-id",
app_id="app-id",
workflow_run_id="workflow-run",
node_id="node-id",
form_definition=definition.model_dump_json(),
rendered_content="Rendered block",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
content = HumanInputContentModel(
id="content-msg-1",
form_id=form.id,
message_id="msg-1",
workflow_run_id=form.workflow_run_id,
)
content.form = form
recipient = HumanInputFormRecipient(
form_id=form.id,
delivery_id="delivery-1",
recipient_type=RecipientType.CONSOLE,
recipient_payload=ConsoleRecipientPayload(account_id=None).model_dump_json(),
access_token="token-1",
)
repository = SQLAlchemyExecutionExtraContentRepository(
session_maker=_FakeSessionMaker(session=_FakeSession(values=[[content], [recipient]]))
)
result = repository.get_by_message_ids(["msg-1"])
assert len(result) == 1
assert len(result[0]) == 1
domain_content = result[0][0]
assert domain_content.submitted is False
assert domain_content.workflow_run_id == "workflow-run"
assert domain_content.form_definition is not None
assert domain_content.form_definition.expiration_time == int(form.expiration_time.timestamp())
assert domain_content.form_definition is not None
form_definition = domain_content.form_definition
assert form_definition.form_id == "form-1"
assert form_definition.node_id == "node-id"
assert form_definition.node_title == "Approval"
assert form_definition.form_content == "Rendered block"
assert form_definition.display_in_ui is True
assert form_definition.form_token == "token-1"
assert form_definition.resolved_default_values == {"name": "John"}
assert form_definition.expiration_time == int(form.expiration_time.timestamp())

View File

@ -169,12 +169,6 @@ version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" }
[[package]]
name = "alibabacloud-endpoint-util"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
[[package]]
name = "alibabacloud-gateway-spi"
version = "0.0.3"
@ -186,69 +180,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf7
[[package]]
name = "alibabacloud-gpdb20160503"
version = "3.8.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-endpoint-util" },
{ name = "alibabacloud-openapi-util" },
{ name = "alibabacloud-openplatform20191219" },
{ name = "alibabacloud-oss-sdk" },
{ name = "alibabacloud-oss-util" },
{ name = "alibabacloud-tea-fileform" },
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-tea-util" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/6a/cc72e744e95c8f37fa6a84e66ae0b9b57a13ee97a0ef03d94c7127c31d75/alibabacloud_gpdb20160503-3.8.3.tar.gz", hash = "sha256:4dfcc0d9cff5a921d529d76f4bf97e2ceb9dc2fa53f00ab055f08509423d8e30", size = 155092, upload-time = "2024-07-18T17:09:42.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/36/bce41704b3bf59d607590ec73a42a254c5dea27c0f707aee11d20512a200/alibabacloud_gpdb20160503-3.8.3-py3-none-any.whl", hash = "sha256:06e1c46ce5e4e9d1bcae76e76e51034196c625799d06b2efec8d46a7df323fe8", size = 156097, upload-time = "2024-07-18T17:09:40.414Z" },
]
[[package]]
name = "alibabacloud-openapi-util"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea-util" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/50/5f41ab550d7874c623f6e992758429802c4b52a6804db437017e5387de33/alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8", size = 7201, upload-time = "2023-10-23T07:44:18.523Z" }
[[package]]
name = "alibabacloud-openplatform20191219"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-endpoint-util" },
{ name = "alibabacloud-openapi-util" },
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-tea-util" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/bf/f7fa2f3657ed352870f442434cb2f27b7f70dcd52a544a1f3998eeaf6d71/alibabacloud_openplatform20191219-2.0.0.tar.gz", hash = "sha256:e67f4c337b7542538746592c6a474bd4ae3a9edccdf62e11a32ca61fad3c9020", size = 5038, upload-time = "2022-09-21T06:16:10.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/e5/18c75213551eeca9db1f6b41ddcc0bd87b5b6508c75a67f05cd8671847b4/alibabacloud_openplatform20191219-2.0.0-py3-none-any.whl", hash = "sha256:873821c45bca72a6c6ec7a906c9cb21554c122e88893bbac3986934dab30dd36", size = 5204, upload-time = "2022-09-21T06:16:07.844Z" },
]
[[package]]
name = "alibabacloud-oss-sdk"
version = "0.1.1"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
{ name = "alibabacloud-oss-util" },
{ name = "alibabacloud-tea-fileform" },
{ name = "alibabacloud-tea-util" },
{ name = "alibabacloud-tea-xml" },
{ name = "alibabacloud-tea-openapi" },
{ name = "darabonba-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7e/d1/f442dd026908fcf55340ca694bb1d027aa91e119e76ae2fbea62f2bde4f4/alibabacloud_oss_sdk-0.1.1.tar.gz", hash = "sha256:f51a368020d0964fcc0978f96736006f49f5ab6a4a4bf4f0b8549e2c659e7358", size = 46434, upload-time = "2025-04-22T12:40:41.717Z" }
[[package]]
name = "alibabacloud-oss-util"
version = "0.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
sdist = { url = "https://files.pythonhosted.org/packages/b3/36/69333c7fb7fb5267f338371b14fdd8dbdd503717c97bbc7a6419d155ab4c/alibabacloud_gpdb20160503-5.1.0.tar.gz", hash = "sha256:086ec6d5e39b64f54d0e44bb3fd4fde1a4822a53eb9f6ff7464dff7d19b07b63", size = 295641, upload-time = "2026-03-19T10:09:02.444Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/7f/a91a2f9ad97c92fa9a6981587ea0ff789240cea05b17b17b7c244e5bac64/alibabacloud_gpdb20160503-5.1.0-py3-none-any.whl", hash = "sha256:580e4579285a54c7f04570782e0f60423a1997568684187fe88e4110acfb640e", size = 848784, upload-time = "2026-03-19T10:09:00.72Z" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/7c/d7e812b9968247a302573daebcfef95d0f9a718f7b4bfcca8d3d83e266be/alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6", size = 10008, upload-time = "2021-04-28T09:25:04.056Z" }
[[package]]
name = "alibabacloud-tea"
@ -260,15 +202,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" }
[[package]]
name = "alibabacloud-tea-fileform"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/8a/ef8ddf5ee0350984cad2749414b420369fe943e15e6d96b79be45367630e/alibabacloud_tea_fileform-0.0.5.tar.gz", hash = "sha256:fd00a8c9d85e785a7655059e9651f9e91784678881831f60589172387b968ee8", size = 3961, upload-time = "2021-04-28T09:22:54.56Z" }
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.3"
@ -297,15 +230,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" },
]
[[package]]
name = "alibabacloud-tea-xml"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" }
[[package]]
name = "aliyun-log-python-sdk"
version = "0.9.37"
@ -570,28 +494,28 @@ wheels = [
[[package]]
name = "basedpyright"
version = "1.38.2"
version = "1.38.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/a3/20aa7c4e83f2f614e0036300f3c352775dede0655c66814da16c37b661a9/basedpyright-1.38.2.tar.gz", hash = "sha256:b433b2b8ba745ed7520cdc79a29a03682f3fb00346d272ece5944e9e5e5daa92", size = 25277019, upload-time = "2026-02-26T11:18:43.594Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/58/7abba2c743571a42b2548f07aee556ebc1e4d0bc2b277aeba1ee6c83b0af/basedpyright-1.38.3.tar.gz", hash = "sha256:9725419786afbfad8a9539527f162da02d462afad440b0412fdb3f3cdf179b90", size = 25277430, upload-time = "2026-03-17T13:10:41.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/12/736cab83626fea3fe65cdafb3ef3d2ee9480c56723f2fd33921537289a5e/basedpyright-1.38.2-py3-none-any.whl", hash = "sha256:153481d37fd19f9e3adedc8629d1d071b10c5f5e49321fb026b74444b7c70e24", size = 12312475, upload-time = "2026-02-26T11:18:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e3/3ebb5c23bd3abb5fc2053b8a06a889aa5c1cf8cff738c78cb6c1957e90cd/basedpyright-1.38.3-py3-none-any.whl", hash = "sha256:1f15c2e489c67d6c5e896c24b6a63251195c04223a55e4568b8f8e8ed49ca830", size = 12313363, upload-time = "2026-03-17T13:10:47.344Z" },
]
[[package]]
name = "bce-python-sdk"
version = "0.9.63"
version = "0.9.64"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "future" },
{ name = "pycryptodome" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/ab/4c2927b01a97562af6a296b722eee79658335795f341a395a12742d5e1a3/bce_python_sdk-0.9.63.tar.gz", hash = "sha256:0c80bc3ac128a0a144bae3b8dff1f397f42c30b36f7677e3a39d8df8e77b1088", size = 284419, upload-time = "2026-03-06T14:54:06.592Z" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/047e9c1a6c97e0cd4d93a6490abd8fbc2ccd13569462fc0228699edc08bc/bce_python_sdk-0.9.64.tar.gz", hash = "sha256:901bf787c26ad35855a80d65e58d7584c8541f7f0f2af20847830e572e5b622e", size = 287125, upload-time = "2026-03-17T11:24:29.345Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/a4/501e978776c7060aa8ba77e68536597e754d938bcdbe1826618acebfbddf/bce_python_sdk-0.9.63-py3-none-any.whl", hash = "sha256:ec66eee8807c6aa4036412592da7e8c9e2cd7fdec494190986288ac2195d8276", size = 400305, upload-time = "2026-03-06T14:53:52.887Z" },
{ url = "https://files.pythonhosted.org/packages/48/7f/dd289582f37ab4effea47b2a8503880db4781ca0fc8e0a8ed5ff493359e5/bce_python_sdk-0.9.64-py3-none-any.whl", hash = "sha256:eaad97e4f0e7d613ae978da3cdc5294e9f724ffca2735f79820037fa1317cd6d", size = 402233, upload-time = "2026-03-17T11:24:24.673Z" },
]
[[package]]
@ -720,16 +644,16 @@ wheels = [
[[package]]
name = "boto3-stubs"
version = "1.42.68"
version = "1.42.73"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/c3/fcc47102c63278af25ad57c93d97dc393f4dbc54c0117a29c78f2b96ec1e/boto3_stubs-1.42.73.tar.gz", hash = "sha256:36f625769b5505c4bc627f16244b98de9e10dae3ac36f1aa0f0ebe2f201dc138", size = 101373, upload-time = "2026-03-20T19:59:51.463Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" },
{ url = "https://files.pythonhosted.org/packages/4b/57/d570ba61a2a0c7fe0c8667e41269a0480293cb53e1786d6661a2bd827fc5/boto3_stubs-1.42.73-py3-none-any.whl", hash = "sha256:bd658429069d8215247fc3abc003220cd875c24ab6eda7b3405090408afaacdf", size = 70009, upload-time = "2026-03-20T19:59:43.786Z" },
]
[package.optional-dependencies]
@ -1290,41 +1214,41 @@ wheels = [
[[package]]
name = "coverage"
version = "7.13.4"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[package.optional-dependencies]
@ -1912,7 +1836,7 @@ tools = [
{ name = "nltk", specifier = "~=3.9.1" },
]
vdb = [
{ name = "alibabacloud-gpdb20160503", specifier = "~=3.8.0" },
{ name = "alibabacloud-gpdb20160503", specifier = "~=5.1.0" },
{ name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" },
{ name = "chromadb", specifier = "==0.5.20" },
{ name = "clickhouse-connect", specifier = "~=0.14.1" },
@ -2619,7 +2543,7 @@ wheels = [
[[package]]
name = "google-cloud-storage"
version = "3.9.0"
version = "3.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@ -2629,9 +2553,9 @@ dependencies = [
{ name = "google-resumable-media" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/e3/747759eebc72e420c25903d6bc231d0ceb110b66ac7e6ee3f350417152cd/google_cloud_storage-3.10.0.tar.gz", hash = "sha256:1aeebf097c27d718d84077059a28d7e87f136f3700212215f1ceeae1d1c5d504", size = 17309829, upload-time = "2026-03-18T15:54:11.875Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" },
{ url = "https://files.pythonhosted.org/packages/29/e2/d58442f4daee5babd9255cf492a1f3d114357164072f8339a22a3ad460a2/google_cloud_storage-3.10.0-py3-none-any.whl", hash = "sha256:0072e7783b201e45af78fd9779894cdb6bec2bf922ee932f3fcc16f8bce9b9a3", size = 324382, upload-time = "2026-03-18T15:54:10.091Z" },
]
[[package]]
@ -6057,27 +5981,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.6"
version = "0.15.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
]
[[package]]
@ -6116,14 +6040,14 @@ wheels = [
[[package]]
name = "scipy-stubs"
version = "1.17.1.2"
version = "1.17.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "optype", extra = ["numpy"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/43f681ffba42f363b7ed6b767fd215d1e26006578214ff8330586a11bf95/scipy_stubs-1.17.1.2.tar.gz", hash = "sha256:2ecadc8c87a3b61aaf7379d6d6b10f1038a829c53b9efe5b174fb97fc8b52237", size = 388354, upload-time = "2026-03-15T22:33:20.449Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/0b/ec4fe720c1202d9df729a3e9d9b7e4d2da9f6e7f28bd2877b7d0769f4f75/scipy_stubs-1.17.1.2-py3-none-any.whl", hash = "sha256:f19e8f5273dbe3b7ee6a9554678c3973b9695fa66b91f29206d00830a1536c06", size = 594377, upload-time = "2026-03-15T22:33:18.684Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
]
[[package]]
@ -6803,11 +6727,11 @@ wheels = [
[[package]]
name = "types-cachetools"
version = "6.2.0.20251022"
version = "6.2.0.20260317"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/a8/f9bcc7f1be63af43ef0170a773e2d88817bcc7c9d8769f2228c802826efe/types_cachetools-6.2.0.20251022.tar.gz", hash = "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef", size = 9608, upload-time = "2025-10-22T03:03:58.16Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/7f/16a4d8344c28193a5a74358028c2d2f753f0d9658dd98b9e1967c50045a2/types_cachetools-6.2.0.20260317.tar.gz", hash = "sha256:6d91855bcc944665897c125e720aa3c80aace929b77a64e796343701df4f61c6", size = 9812, upload-time = "2026-03-17T04:06:32.007Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/2d/8d821ed80f6c2c5b427f650bf4dc25b80676ed63d03388e4b637d2557107/types_cachetools-6.2.0.20251022-py3-none-any.whl", hash = "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", size = 9341, upload-time = "2025-10-22T03:03:57.036Z" },
{ url = "https://files.pythonhosted.org/packages/17/9a/b00b23054934c4d569c19f7278c4fb32746cd36a64a175a216d3073a4713/types_cachetools-6.2.0.20260317-py3-none-any.whl", hash = "sha256:92fa9bc50e4629e31fca67ceb3fb1de71791e314fa16c0a0d2728724dc222c8b", size = 9346, upload-time = "2026-03-17T04:06:31.184Z" },
]
[[package]]
@ -6851,11 +6775,11 @@ wheels = [
[[package]]
name = "types-docutils"
version = "0.22.3.20260316"
version = "0.22.3.20260322"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" }
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
]
[[package]]
@ -6885,15 +6809,15 @@ wheels = [
[[package]]
name = "types-gevent"
version = "25.9.0.20251228"
version = "25.9.0.20260322"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-greenlet" },
{ name = "types-psutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/85/c5043c4472f82c8ee3d9e0673eb4093c7d16770a26541a137a53a1d096f6/types_gevent-25.9.0.20251228.tar.gz", hash = "sha256:423ef9891d25c5a3af236c3e9aace4c444c86ff773fe13ef22731bc61d59abef", size = 38063, upload-time = "2025-12-28T03:28:28.651Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/f0/14a99ddcaa69b559fa7cec8c9de880b792bebb0b848ae865d94ea9058533/types_gevent-25.9.0.20260322.tar.gz", hash = "sha256:91257920845762f09753c08aa20fad1743ac13d2de8bcf23f4b8fe967d803732", size = 38241, upload-time = "2026-03-22T04:08:55.213Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/b7/a2d6b652ab5a26318b68cafd58c46fafb9b15c5313d2d76a70b838febb4b/types_gevent-25.9.0.20251228-py3-none-any.whl", hash = "sha256:e2e225af4fface9241c16044983eb2fc3993f2d13d801f55c2932848649b7f2f", size = 55486, upload-time = "2025-12-28T03:28:27.382Z" },
{ url = "https://files.pythonhosted.org/packages/89/0f/964440b57eb4ddb4aca03479a4093852e1ce79010d1c5967234e6f5d6bd9/types_gevent-25.9.0.20260322-py3-none-any.whl", hash = "sha256:21b3c269b3a20ecb0e4668289c63b97d21694d84a004ab059c1e32ab970eacc2", size = 55500, upload-time = "2026-03-22T04:08:54.103Z" },
]
[[package]]
@ -6976,11 +6900,11 @@ wheels = [
[[package]]
name = "types-openpyxl"
version = "3.1.5.20260316"
version = "3.1.5.20260322"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/38/32f8ee633dd66ca6d52b8853b9fd45dc3869490195a6ed435d5c868b9c2d/types_openpyxl-3.1.5.20260316.tar.gz", hash = "sha256:081dda9427ea1141e5649e3dcf630e7013a4cf254a5862a7e0a3f53c123b7ceb", size = 101318, upload-time = "2026-03-16T04:29:05.004Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/bf/15240de4d68192d2a1f385ef2f6f1ecb29b85d2f3791dd2e2d5b980be30f/types_openpyxl-3.1.5.20260322.tar.gz", hash = "sha256:a61d66ebe1e49697853c6db8e0929e1cda2c96755e71fb676ed7fc48dfdcf697", size = 101325, upload-time = "2026-03-22T04:08:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/df/b87ae6226ed7cc84b9e43119c489c7f053a9a25e209e0ebb5d84bc36fa37/types_openpyxl-3.1.5.20260316-py3-none-any.whl", hash = "sha256:38e7e125df520fb7eb72cb1129c9f024eb99ef9564aad2c27f68f080c26bcf2d", size = 166084, upload-time = "2026-03-16T04:29:03.657Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b4/c14191b30bcb266365b124b2bb4e67ecd68425a78ba77ee026f33667daa9/types_openpyxl-3.1.5.20260322-py3-none-any.whl", hash = "sha256:2f515f0b0bbfb04bfb587de34f7522d90b5151a8da7bbbd11ecec4ca40f64238", size = 166102, upload-time = "2026-03-22T04:08:39.174Z" },
]
[[package]]
@ -7055,11 +6979,11 @@ wheels = [
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260305"
version = "2.9.0.20260323"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/02/f72df9ef5ffc4f959b83cb80c8aa03eb8718a43e563ecd99ccffe265fa89/types_python_dateutil-2.9.0.20260323.tar.gz", hash = "sha256:a107aef5841db41ace381dbbbd7e4945220fc940f7a72172a0be5a92d9ab7164", size = 16897, upload-time = "2026-03-23T04:15:14.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/92/c1/b661838b97453e699a215451f2e22cee750eaaf4ea4619b34bdaf01221a4/types_python_dateutil-2.9.0.20260323-py3-none-any.whl", hash = "sha256:a23a50a07f6eb87e729d4cb0c2eb511c81761eeb3f505db2c1413be94aae8335", size = 18433, upload-time = "2026-03-23T04:15:13.683Z" },
]
[[package]]
@ -7073,11 +6997,11 @@ wheels = [
[[package]]
name = "types-pywin32"
version = "311.0.0.20260316"
version = "311.0.0.20260323"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/a8/b4652002a854fcfe5d272872a0ae2d5df0e9dc482e1a6dfb5e97b905b76f/types_pywin32-311.0.0.20260316.tar.gz", hash = "sha256:c136fa489fe6279a13bca167b750414e18d657169b7cf398025856dc363004e8", size = 329956, upload-time = "2026-03-16T04:28:57.366Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/cc/f03ddb7412ac2fc2238358b617c2d5919ba96812dff8d3081f3b2754bb83/types_pywin32-311.0.0.20260323.tar.gz", hash = "sha256:2e8dc6a59fedccbc51b241651ce1e8aa58488934f517debf23a9c6d0ff329b4b", size = 332263, upload-time = "2026-03-23T04:15:20.004Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/83/704698d93788cf1c2f5e236eae2b37f1b2152ef84dc66b4b83f6c7487b76/types_pywin32-311.0.0.20260316-py3-none-any.whl", hash = "sha256:abb643d50012386d697af49384cc0e6e475eab76b0ca2a7f93d480d0862b3692", size = 392959, upload-time = "2026-03-16T04:28:56.104Z" },
{ url = "https://files.pythonhosted.org/packages/dc/82/d786d5d8b846e3cbe1ee52da8945560b111c789b42c3771b2129b312ab94/types_pywin32-311.0.0.20260323-py3-none-any.whl", hash = "sha256:2f2b03fc72ae77ccbb0ee258da0f181c3a38bd8602f6e332e42587b3b0d5f095", size = 395435, upload-time = "2026-03-23T04:15:18.76Z" },
]
[[package]]
@ -7173,16 +7097,16 @@ wheels = [
[[package]]
name = "types-tensorflow"
version = "2.18.0.20260224"
version = "2.18.0.20260322"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "types-protobuf" },
{ name = "types-requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/4914c2fbc1cf8a8d1ef2a7c727bb6f694879be85edeee880a0c88e696af8/types_tensorflow-2.18.0.20260224.tar.gz", hash = "sha256:9b0ccc91c79c88791e43d3f80d6c879748fa0361409c5ff23c7ffe3709be00f2", size = 258786, upload-time = "2026-02-24T04:06:45.613Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/cb/81dfaa2680031a6e087bcdfaf1c0556371098e229aee541e21c81a381065/types_tensorflow-2.18.0.20260322.tar.gz", hash = "sha256:135dc6ca06cc647a002e1bca5c5c99516fde51efd08e46c48a9b1916fc5df07f", size = 259030, upload-time = "2026-03-22T04:09:14.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/1d/a1c3c60f0eb1a204500dbdc66e3d18aafabc86ad07a8eca71ea05bc8c5a8/types_tensorflow-2.18.0.20260224-py3-none-any.whl", hash = "sha256:6a25f5f41f3e06f28c1f65c6e09f484d4ba0031d6d8df83a39df9d890245eefc", size = 329746, upload-time = "2026-02-24T04:06:44.4Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0c/a178061450b640e53577e2c423ad22bf5d3f692f6bfeeb12156d02b531ef/types_tensorflow-2.18.0.20260322-py3-none-any.whl", hash = "sha256:d8776b6daacdb279e64f105f9dcbc0b8e3544b9a2f2eb71ec6ea5955081f65e6", size = 329771, upload-time = "2026-03-22T04:09:12.844Z" },
]
[[package]]

440
docker/dify-env-sync.py Executable file
View File

@ -0,0 +1,440 @@
#!/usr/bin/env python3
# ================================================================
# Dify Environment Variables Synchronization Script
#
# Features:
# - Synchronize latest settings from .env.example to .env
# - Preserve custom settings in existing .env
# - Add new environment variables
# - Detect removed environment variables
# - Create backup files
# ================================================================
import argparse
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
# ANSI color codes
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m" # No Color
def supports_color() -> bool:
"""Return True if the terminal supports ANSI color codes."""
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
def log_info(message: str) -> None:
"""Print an informational message in blue."""
if supports_color():
print(f"{BLUE}[INFO]{NC} {message}")
else:
print(f"[INFO] {message}")
def log_success(message: str) -> None:
"""Print a success message in green."""
if supports_color():
print(f"{GREEN}[SUCCESS]{NC} {message}")
else:
print(f"[SUCCESS] {message}")
def log_warning(message: str) -> None:
"""Print a warning message in yellow to stderr."""
if supports_color():
print(f"{YELLOW}[WARNING]{NC} {message}", file=sys.stderr)
else:
print(f"[WARNING] {message}", file=sys.stderr)
def log_error(message: str) -> None:
"""Print an error message in red to stderr."""
if supports_color():
print(f"{RED}[ERROR]{NC} {message}", file=sys.stderr)
else:
print(f"[ERROR] {message}", file=sys.stderr)
def parse_env_file(path: Path) -> dict[str, str]:
"""Parse an .env-style file and return a mapping of key to raw value.
Lines that are blank or start with '#' (after optional whitespace) are
skipped. Only lines containing '=' are considered variable definitions.
Args:
path: Path to the .env file to parse.
Returns:
Ordered dict mapping variable name to its value string.
"""
variables: dict[str, str] = {}
with path.open(encoding="utf-8") as fh:
for line in fh:
line = line.rstrip("\n")
# Skip blank lines and comment lines
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if key:
variables[key] = value.strip()
return variables
def check_files(work_dir: Path) -> None:
"""Verify required files exist; create .env from .env.example if absent.
Args:
work_dir: Directory that must contain .env.example (and optionally .env).
Raises:
SystemExit: If .env.example does not exist.
"""
log_info("Checking required files...")
example_file = work_dir / ".env.example"
env_file = work_dir / ".env"
if not example_file.exists():
log_error(".env.example file not found")
sys.exit(1)
if not env_file.exists():
log_warning(".env file does not exist. Creating from .env.example.")
shutil.copy2(example_file, env_file)
log_success(".env file created")
log_success("Required files verified")
def create_backup(work_dir: Path) -> None:
"""Create a timestamped backup of the current .env file.
Backups are placed in ``<work_dir>/env-backup/`` with the filename
``.env.backup_<YYYYMMDD_HHMMSS>``.
Args:
work_dir: Directory containing the .env file to back up.
"""
env_file = work_dir / ".env"
if not env_file.exists():
return
backup_dir = work_dir / "env-backup"
if not backup_dir.exists():
backup_dir.mkdir(parents=True)
log_info(f"Created backup directory: {backup_dir}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = backup_dir / f".env.backup_{timestamp}"
shutil.copy2(env_file, backup_file)
log_success(f"Backed up existing .env to {backup_file}")
def analyze_value_change(current: str, recommended: str) -> str | None:
"""Analyse what kind of change occurred between two env values.
Args:
current: Value currently set in .env.
recommended: Value present in .env.example.
Returns:
A human-readable description string, or None when no analysis applies.
"""
use_colors = supports_color()
def colorize(color: str, text: str) -> str:
return f"{color}{text}{NC}" if use_colors else text
if not current and recommended:
return colorize(RED, " -> Setting from empty to recommended value")
if current and not recommended:
return colorize(RED, " -> Recommended value changed to empty")
# Numeric comparison
if re.fullmatch(r"\d+", current) and re.fullmatch(r"\d+", recommended):
cur_int, rec_int = int(current), int(recommended)
if cur_int < rec_int:
return colorize(BLUE, f" -> Numeric increase ({current} < {recommended})")
if cur_int > rec_int:
return colorize(YELLOW, f" -> Numeric decrease ({current} > {recommended})")
return None
# Boolean comparison
if current.lower() in {"true", "false"} and recommended.lower() in {"true", "false"}:
if current.lower() != recommended.lower():
return colorize(BLUE, f" -> Boolean value change ({current} -> {recommended})")
return None
# URL / endpoint
if current.startswith(("http://", "https://")) or recommended.startswith(("http://", "https://")):
return colorize(BLUE, " -> URL/endpoint change")
# File path
if current.startswith("/") or recommended.startswith("/"):
return colorize(BLUE, " -> File path change")
# String length
if len(current) != len(recommended):
return colorize(YELLOW, f" -> String length change ({len(current)} -> {len(recommended)} characters)")
return None
def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]:
"""Find variables whose values differ between .env and .env.example.
Only variables present in *both* files are compared; new or removed
variables are handled by separate functions.
Args:
env_vars: Parsed key/value pairs from .env.
example_vars: Parsed key/value pairs from .env.example.
Returns:
Mapping of key -> (env_value, example_value) for every key whose
values differ.
"""
log_info("Detecting differences between .env and .env.example...")
diffs: dict[str, tuple[str, str]] = {}
for key, example_value in example_vars.items():
if key in env_vars and env_vars[key] != example_value:
diffs[key] = (env_vars[key], example_value)
if diffs:
log_success(f"Detected differences in {len(diffs)} environment variables")
show_differences_detail(diffs)
else:
log_info("No differences detected")
return diffs
def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
"""Print a formatted table of differing environment variables.
Args:
diffs: Mapping of key -> (current_value, recommended_value).
"""
use_colors = supports_color()
log_info("")
log_info("=== Environment Variable Differences ===")
if not diffs:
log_info("No differences to display")
return
for count, (key, (env_value, example_value)) in enumerate(diffs.items(), start=1):
print()
if use_colors:
print(f"{YELLOW}[{count}] {key}{NC}")
print(f" {GREEN}.env (current){NC} : {env_value}")
print(f" {BLUE}.env.example (recommended){NC} : {example_value}")
else:
print(f"[{count}] {key}")
print(f" .env (current) : {env_value}")
print(f" .env.example (recommended) : {example_value}")
analysis = analyze_value_change(env_value, example_value)
if analysis:
print(analysis)
print()
log_info("=== Difference Analysis Complete ===")
log_info("Note: Consider changing to the recommended values above.")
log_info("Current implementation preserves .env values.")
print()
def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]:
"""Identify variables present in .env but absent from .env.example.
Args:
env_vars: Parsed key/value pairs from .env.
example_vars: Parsed key/value pairs from .env.example.
Returns:
Sorted list of variable names that no longer appear in .env.example.
"""
log_info("Detecting removed environment variables...")
removed = sorted(set(env_vars) - set(example_vars))
if removed:
log_warning("The following environment variables have been removed from .env.example:")
for var in removed:
log_warning(f" - {var}")
log_warning("Consider manually removing these variables from .env")
else:
log_success("No removed environment variables found")
return removed
def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None:
"""Rewrite .env based on .env.example while preserving custom values.
The output file follows the exact line structure of .env.example
(preserving comments, blank lines, and ordering). For every variable
that exists in .env with a different value from the example, the
current .env value is kept. Variables that are new in .env.example
(not present in .env at all) are added with the example's default.
Args:
work_dir: Directory containing .env and .env.example.
env_vars: Parsed key/value pairs from the original .env.
diffs: Keys whose .env values differ from .env.example (to preserve).
"""
log_info("Starting partial synchronization of .env file...")
example_file = work_dir / ".env.example"
new_env_file = work_dir / ".env.new"
# Keys whose current .env value should override the example default
preserved_keys: set[str] = set(diffs.keys())
preserved_count = 0
updated_count = 0
env_var_pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
with example_file.open(encoding="utf-8") as src, new_env_file.open("w", encoding="utf-8") as dst:
for line in src:
raw_line = line.rstrip("\n")
match = env_var_pattern.match(raw_line)
if match:
key = match.group(1)
if key in preserved_keys:
# Write the preserved value from .env
dst.write(f"{key}={env_vars[key]}\n")
log_info(f" Preserved: {key} (.env value)")
preserved_count += 1
else:
# Use the example value (covers new vars and unchanged ones)
dst.write(line if line.endswith("\n") else raw_line + "\n")
updated_count += 1
else:
# Blank line, comment, or non-variable line — keep as-is
dst.write(line if line.endswith("\n") else raw_line + "\n")
# Atomically replace the original .env
try:
new_env_file.replace(work_dir / ".env")
except OSError as exc:
log_error(f"Failed to replace .env file: {exc}")
new_env_file.unlink(missing_ok=True)
sys.exit(1)
log_success("Successfully created new .env file")
log_success("Partial synchronization of .env file completed")
log_info(f" Preserved .env values: {preserved_count}")
log_info(f" Updated to .env.example values: {updated_count}")
def show_statistics(work_dir: Path) -> None:
"""Print a summary of variable counts from both env files.
Args:
work_dir: Directory containing .env and .env.example.
"""
log_info("Synchronization statistics:")
example_file = work_dir / ".env.example"
env_file = work_dir / ".env"
example_count = len(parse_env_file(example_file)) if example_file.exists() else 0
env_count = len(parse_env_file(env_file)) if env_file.exists() else 0
log_info(f" .env.example environment variables: {example_count}")
log_info(f" .env environment variables: {env_count}")
def build_arg_parser() -> argparse.ArgumentParser:
"""Build and return the CLI argument parser.
Returns:
Configured ArgumentParser instance.
"""
parser = argparse.ArgumentParser(
prog="dify-env-sync",
description=(
"Synchronize .env with .env.example: add new variables, "
"preserve custom values, and report removed variables."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" # Run from the docker/ directory (default)\n"
" python dify-env-sync.py\n\n"
" # Specify a custom working directory\n"
" python dify-env-sync.py --dir /path/to/docker\n"
),
)
parser.add_argument(
"--dir",
metavar="DIRECTORY",
default=".",
help="Working directory containing .env and .env.example (default: current directory)",
)
parser.add_argument(
"--no-backup",
action="store_true",
default=False,
help="Skip creating a timestamped backup of the existing .env file",
)
return parser
def main() -> None:
"""Orchestrate the complete environment variable synchronization process."""
parser = build_arg_parser()
args = parser.parse_args()
work_dir = Path(args.dir).resolve()
log_info("=== Dify Environment Variables Synchronization Script ===")
log_info(f"Execution started: {datetime.now()}")
log_info(f"Working directory: {work_dir}")
# 1. Verify prerequisites
check_files(work_dir)
# 2. Backup existing .env
if not args.no_backup:
create_backup(work_dir)
# 3. Parse both files
env_vars = parse_env_file(work_dir / ".env")
example_vars = parse_env_file(work_dir / ".env.example")
# 4. Report differences (values that changed in the example)
diffs = detect_differences(env_vars, example_vars)
# 5. Report variables removed from the example
detect_removed_variables(env_vars, example_vars)
# 6. Rewrite .env
sync_env_file(work_dir, env_vars, diffs)
# 7. Print summary statistics
show_statistics(work_dir)
log_success("=== Synchronization process completed successfully ===")
log_info(f"Execution finished: {datetime.now()}")
if __name__ == "__main__":
main()

View File

@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({
<span data-testid="select-value">{value}</span>
<span data-testid="select-items-count">{items.length}</span>
{items.map((item: MockSelectItem) => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
<button type="button" key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}

View File

@ -78,6 +78,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
<Modal
isShow
onClose={noop}
wrapperClassName="z-[1002]"
className="!w-[640px] !max-w-none !p-8 !pb-6"
>
<div className="mb-2 text-xl font-semibold text-text-primary">

View File

@ -69,7 +69,7 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[102] w-[calc(100%-32px)] max-w-[576px]">
<PortalToFollowElemContent className="z-[1002] w-[calc(100%-32px)] max-w-[576px]">
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pb-1 pt-2">

View File

@ -84,7 +84,7 @@ const Configure = ({
{t('dataSource.configure', { ns: 'common' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[61]">
<PortalToFollowElemContent className="z-[1002]">
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
{
!!canOAuth && (
@ -104,7 +104,7 @@ const Configure = ({
}
{
!!canApiKey && !!canOAuth && (
<div className="system-2xs-medium-uppercase flex h-4 items-center p-2 text-text-quaternary">
<div className="flex h-4 items-center p-2 text-text-quaternary system-2xs-medium-uppercase">
<div className="mr-2 h-[1px] grow bg-gradient-to-l from-[rgba(16,24,40,0.08)]" />
OR
<div className="ml-2 h-[1px] grow bg-gradient-to-r from-[rgba(16,24,40,0.08)]" />

View File

@ -39,7 +39,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('auth.setDefault', { ns: 'plugin' })}</div>
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
</div>
),
},
@ -51,7 +51,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('operation.rename', { ns: 'common' })}</div>
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
</div>
),
},
@ -66,7 +66,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('operation.edit', { ns: 'common' })}</div>
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
</div>
),
},
@ -81,7 +81,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold mb-1 text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</div>
),
},
@ -98,7 +98,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">
<div className="text-text-secondary system-sm-semibold">
{t('operation.remove', { ns: 'common' })}
</div>
</div>
@ -122,7 +122,7 @@ const Operator = ({
items={items}
secondItems={secondItems}
onSelect={handleSelect}
popupClassName="z-[61]"
popupClassName="z-[1002]"
triggerProps={{
size: 'l',
}}

View File

@ -2,11 +2,15 @@ import type { InvitationResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import InviteModal from '../index'
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
useProviderContext: vi.fn(() => ({
@ -14,6 +18,11 @@ vi.mock('@/context/provider-context', () => ({
})),
}))
vi.mock('@/service/common')
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
@ -37,7 +46,6 @@ describe('InviteModal', () => {
const mockOnCancel = vi.fn()
const mockOnSend = vi.fn()
const mockRefreshLicenseLimit = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
@ -49,10 +57,11 @@ describe('InviteModal', () => {
})
const renderModal = (isEmailSetup = true) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
</ToastContext.Provider>,
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />,
)
const fillEmails = (value: string) => {
fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } })
}
it('should render invite modal content', async () => {
renderModal()
@ -68,12 +77,8 @@ describe('InviteModal', () => {
})
it('should enable send button after entering an email', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
@ -84,7 +89,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -103,8 +108,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -116,8 +120,6 @@ describe('InviteModal', () => {
})
it('should keep send button disabled when license limit is exceeded', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
@ -125,8 +127,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
@ -144,15 +145,11 @@ describe('InviteModal', () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
// Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD)
await user.type(input, 'invalid@email.c')
fillEmails('invalid@email.c')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.members.emailInvalid',
})
expect(toast.error).toHaveBeenCalledWith('common.members.emailInvalid')
expect(inviteMember).not.toHaveBeenCalled()
})
@ -160,8 +157,7 @@ describe('InviteModal', () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByText('user@example.com')).toBeInTheDocument()
@ -203,7 +199,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -214,8 +210,6 @@ describe('InviteModal', () => {
})
it('should show destructive text color when used size exceeds limit', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
@ -223,8 +217,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
@ -241,8 +234,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@ -264,8 +256,6 @@ describe('InviteModal', () => {
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
const user = userEvent.setup()
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
@ -274,8 +264,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@ -293,8 +282,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@ -320,11 +308,9 @@ describe('InviteModal', () => {
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// isLimited=false → no destructive color
const counter = screen.getByText('1')

View File

@ -1,12 +0,0 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}
.emailsInput {
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
}
.emailBackground {
background-color: white !important;
}

View File

@ -2,20 +2,17 @@
import type { RoleKey } from './role-selector'
import type { InvitationResult } from '@/models/common'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactMultiEmail } from 'react-multi-email'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
import RoleSelector from './role-selector'
import 'react-multi-email/dist/style.css'
@ -34,7 +31,6 @@ const InviteModal = ({
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
const [emails, setEmails] = useState<string[]>([])
const { notify } = useContext(ToastContext)
const [isLimited, setIsLimited] = useState(false)
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
@ -74,21 +70,28 @@ const InviteModal = ({
catch { }
}
else {
notify({ type: 'error', message: t('members.emailInvalid', { ns: 'common' }) })
toast.error(t('members.emailInvalid', { ns: 'common' }))
}
setIsSubmitted()
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
return (
<div className={cn(s.wrap)}>
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
<div className="mb-2 flex justify-between">
<div className="text-xl font-semibold text-text-primary">{t('members.inviteTeamMember', { ns: 'common' })}</div>
<div
data-testid="invite-modal-close"
className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary"
onClick={onCancel}
/>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[400px] overflow-visible px-8 py-6"
>
<DialogCloseButton data-testid="invite-modal-close" className="right-8 top-6" />
<div className="mb-2 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary">
{t('members.inviteTeamMember', { ns: 'common' })}
</DialogTitle>
</div>
<div className="mb-3 text-[13px] text-text-tertiary">{t('members.inviteTeamMemberTip', { ns: 'common' })}</div>
{!isEmailSetup && (
@ -152,8 +155,8 @@ const InviteModal = ({
{t('members.sendInvite', { ns: 'common' })}
</Button>
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,11 +1,10 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -25,115 +24,111 @@ export type RoleSelectorProps = {
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { datasetOperatorEnabled } = useProviderContext()
const [open, setOpen] = React.useState(false)
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<PopoverTrigger
data-testid="role-selector-trigger"
className={cn(
'flex w-full cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
>
<div className="p-1">
<div
data-testid="role-selector-trigger"
className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}
data-testid="role-option-normal"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('normal')
setOpen(false)
}}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div
data-testid="role-option-normal"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('normal')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
{value === 'normal' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-editor"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('editor')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
{value === 'editor' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-admin"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('admin')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
{value === 'admin' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
{datasetOperatorEnabled && (
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
{value === 'normal' && (
<div
data-testid="role-option-dataset_operator"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('dataset_operator')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
{value === 'dataset_operator' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
<div
data-testid="role-option-editor"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('editor')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
{value === 'editor' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-admin"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('admin')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
{value === 'admin' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
{datasetOperatorEnabled && (
<div
data-testid="role-option-dataset_operator"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('dataset_operator')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
{value === 'dataset_operator' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -1,15 +1,10 @@
import type { InvitationResult } from '@/models/common'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { RiQuestionLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import Tooltip from '@/app/components/base/tooltip'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { IS_CE_EDITION } from '@/config'
import s from './index.module.css'
import InvitationLink from './invitation-link'
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
@ -29,8 +24,18 @@ const InvitedModal = ({
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
return (
<div className={s.wrap}>
<Modal isShow onClose={noop} className={s.modal}>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[480px] p-8"
>
<DialogCloseButton className="right-8 top-8" />
<div className="mb-3 flex justify-between">
<div className="
flex h-12 w-12 items-center justify-center rounded-xl
@ -38,11 +43,10 @@ const InvitedModal = ({
shadow-xl
"
>
<CheckCircleIcon className="h-[22px] w-[22px] text-[#039855]" />
<div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" />
</div>
<XMarkIcon className="h-4 w-4 cursor-pointer" onClick={onCancel} />
</div>
<div className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</div>
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</DialogTitle>
{!IS_CE_EDITION && (
<div className="mb-10 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
)}
@ -54,7 +58,7 @@ const InvitedModal = ({
!!successInvitationResults.length
&& (
<>
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
<div className="py-2 text-sm font-medium text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
{successInvitationResults.map(item =>
<InvitationLink key={item.email} value={item} />)}
</>
@ -64,18 +68,23 @@ const InvitedModal = ({
!!failedInvitationResults.length
&& (
<>
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
<div className="py-2 text-sm font-medium text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
<div className="flex flex-wrap justify-between gap-y-1">
{
failedInvitationResults.map(item => (
<div key={item.email} className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1">
<Tooltip
popupContent={item.message}
>
<div className="flex items-center justify-center gap-1 text-sm">
{item.email}
<RiQuestionLine className="h-4 w-4 text-red-300" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<div className="flex items-center justify-center gap-1 text-sm">
{item.email}
<div className="i-ri-question-line h-4 w-4 text-red-300" />
</div>
)}
/>
<TooltipContent>
{item.message}
</TooltipContent>
</Tooltip>
</div>
),
@ -97,8 +106,8 @@ const InvitedModal = ({
{t('members.ok', { ns: 'common' })}
</Button>
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
import { t } from 'i18next'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import s from './index.module.css'
type IInvitationLinkProps = {
@ -38,20 +38,28 @@ const InvitationLink = ({
<div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover" data-testid="invitation-link-container">
<div className="flex h-5 grow items-center">
<div className="relative h-full grow text-[13px]">
<Tooltip
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
>
<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>
<Tooltip>
<TooltipTrigger
render={<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>}
/>
<TooltipContent>
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
</TooltipContent>
</Tooltip>
</div>
<div className="h-4 shrink-0 border bg-divider-regular" />
<Tooltip
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
>
<div className="shrink-0 px-0.5">
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
</div>
</div>
<Tooltip>
<TooltipTrigger
render={(
<div className="shrink-0 px-0.5">
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
</div>
</div>
)}
/>
<TooltipContent>
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
</TooltipContent>
</Tooltip>
</div>
</div>

View File

@ -102,7 +102,7 @@ const Operation = ({
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[999]">
<PortalToFollowElemContent className="z-[1002]">
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
<div className="p-1">
{

View File

@ -141,6 +141,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
<Modal
isShow={show}
onClose={noop}
wrapperClassName="z-[1002]"
className="!w-[420px] !p-6"
>
<div

View File

@ -77,7 +77,7 @@ const MemberSelector: FC<Props> = ({
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<PortalToFollowElemContent className="z-[1002]">
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-2 pb-1">
<Input

View File

@ -116,7 +116,7 @@ const AddCustomModel = ({
>
{renderTrigger(open)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<PortalToFollowElemContent className="z-[1002]">
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="max-h-[304px] overflow-y-auto p-1">
{
@ -136,7 +136,7 @@ const AddCustomModel = ({
modelName={model.model}
/>
<div
className="system-md-regular grow truncate text-text-primary"
className="grow truncate text-text-primary system-md-regular"
title={model.model}
>
{model.model}
@ -148,7 +148,7 @@ const AddCustomModel = ({
{
!notAllowCustomCredential && (
<div
className="system-xs-medium flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only"
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only system-xs-medium"
onClick={() => {
handleOpenModalForAddNewCustomModel()
setOpen(false)

View File

@ -164,7 +164,7 @@ const Authorized = ({
>
{renderTrigger(mergedIsOpen)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<PortalToFollowElemContent className="z-[1002]">
<div className={cn(
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
popupClassName,
@ -172,7 +172,7 @@ const Authorized = ({
>
{
popupTitle && (
<div className="system-xs-medium px-3 pb-0.5 pt-[10px] text-text-tertiary">
<div className="px-3 pb-0.5 pt-[10px] text-text-tertiary system-xs-medium">
{popupTitle}
</div>
)
@ -218,7 +218,7 @@ const Authorized = ({
}
: undefined,
)}
className="system-xs-medium flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only"
className="flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only system-xs-medium"
>
<RiAddLine className="mr-1 h-4 w-4" />
{t('modelProvider.auth.addModelCredential', { ns: 'common' })}

View File

@ -53,14 +53,14 @@ const CredentialSelector = ({
triggerPopupSameWidth
>
<PortalToFollowElemTrigger asChild onClick={() => !disabled && setOpen(v => !v)}>
<div className="system-sm-regular flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2">
<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular">
{
selectedCredential && (
<div className="flex items-center">
{
!selectedCredential.addNewCredential && <Indicator className="ml-1 mr-2 shrink-0" />
}
<div className="system-sm-regular truncate text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
<div className="truncate text-components-input-text-filled system-sm-regular" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
{
selectedCredential.from_enterprise && (
<Badge className="shrink-0">Enterprise</Badge>
@ -71,13 +71,13 @@ const CredentialSelector = ({
}
{
!selectedCredential && (
<div className="system-sm-regular grow truncate text-components-input-text-placeholder">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
<div className="grow truncate text-components-input-text-placeholder system-sm-regular">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
)
}
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<PortalToFollowElemContent className="z-[1002]">
<div className="border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur shadow-lg">
<div className="max-h-[320px] overflow-y-auto p-1">
{
@ -98,7 +98,7 @@ const CredentialSelector = ({
{
!notAllowAddNewCredential && (
<div
className="system-xs-medium flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only"
className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only system-xs-medium"
onClick={handleAddNewCredential}
>
<RiAddLine className="mr-1 h-4 w-4" />

View File

@ -244,6 +244,7 @@ const ModelLoadBalancingModal = ({
<Modal
isShow={Boolean(model) && open}
onClose={onClose}
wrapperClassName="z-[1002]"
className="w-[640px] max-w-none px-8 pt-8"
title={(
<div className="pb-3 font-semibold">

View File

@ -8,6 +8,7 @@ import {
waitFor,
} from '@testing-library/react'
import * as React from 'react'
import { use } from 'react'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
@ -23,14 +24,14 @@ vi.mock('@headlessui/react', () => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<MenuContext.Provider value={value}>
<MenuContext value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
</MenuContext>
)
}
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const context = use(MenuContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
@ -43,7 +44,7 @@ vi.mock('@headlessui/react', () => {
}
const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const context = use(MenuContext)
if (!context?.open)
return null
return (
@ -84,6 +85,26 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
onClick,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string, children?: React.ReactNode }) => (
<a
href={href}
onClick={(event) => {
event.preventDefault()
onClick?.(event)
}}
{...props}
>
{children}
</a>
),
}))
describe('Nav Component', () => {
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()

View File

@ -0,0 +1,532 @@
import type { ToolWithProvider } from '../../types'
import type { ToolValue } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Tool } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTags } from '@/app/components/plugins/hooks'
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 { createCustomCollection } from '@/service/tools'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
useInvalidateAllCustomTools,
useInvalidateAllMCPTools,
useInvalidateAllWorkflowTools,
} from '@/service/use-tools'
import { Theme } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import ToolPicker from '../tool-picker'
const mockNotify = vi.fn()
const mockSetSystemFeatures = vi.fn()
const mockInvalidateBuiltInTools = vi.fn()
const mockInvalidateCustomTools = vi.fn()
const mockInvalidateWorkflowTools = vi.fn()
const mockInvalidateMcpTools = vi.fn()
const mockCreateCustomCollection = vi.mocked(createCustomCollection)
const mockInstallPackageFromMarketPlace = vi.fn()
const mockCheckInstalled = vi.fn()
const mockRefreshPluginList = vi.fn()
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
const mockUseTags = vi.mocked(useTags)
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
const mockUseAllBuiltInTools = vi.mocked(useAllBuiltInTools)
const mockUseAllCustomTools = vi.mocked(useAllCustomTools)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
const mockUseAllMCPTools = vi.mocked(useAllMCPTools)
const mockUseInvalidateAllBuiltInTools = vi.mocked(useInvalidateAllBuiltInTools)
const mockUseInvalidateAllCustomTools = vi.mocked(useInvalidateAllCustomTools)
const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTools)
const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools)
const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations)
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/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/plugins/hooks')>()
return {
...actual,
useTags: vi.fn(),
}
})
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
}))
vi.mock('@/service/tools', () => ({
createCustomCollection: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: vi.fn(),
useDownloadPlugin: vi.fn(() => ({
data: undefined,
isLoading: false,
})),
useInstallPackageFromMarketPlace: () => ({
mutateAsync: mockInstallPackageFromMarketPlace,
isPending: false,
}),
usePluginDeclarationFromMarketPlace: () => ({
data: undefined,
}),
usePluginTaskList: () => ({
handleRefetch: vi.fn(),
}),
useUpdatePackageFromMarketPlace: () => ({
mutateAsync: vi.fn(),
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: vi.fn(),
useAllCustomTools: vi.fn(),
useAllWorkflowTools: vi.fn(),
useAllMCPTools: vi.fn(),
useInvalidateAllBuiltInTools: vi.fn(),
useInvalidateAllCustomTools: vi.fn(),
useInvalidateAllWorkflowTools: vi.fn(),
useInvalidateAllMCPTools: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (payload: unknown) => mockNotify(payload),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: Theme.light }),
}))
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
default: ({
onAdd,
onHide,
}: {
onAdd: (payload: { name: string }) => Promise<void>
onHide: () => void
}) => (
<div data-testid="edit-custom-tool-modal">
<button type="button" onClick={() => onAdd({ name: 'collection-a' })}>submit-custom-tool</button>
<button type="button" onClick={onHide}>hide-custom-tool</button>
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => mockCheckInstalled(),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
default: () => ({
canInstall: true,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: mockRefreshPluginList,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({
check: vi.fn().mockResolvedValue({ status: 'success' }),
stop: vi.fn(),
}),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({
onSuccess,
onClose,
}: {
onSuccess: () => void | Promise<void>
onClose: () => void
}) => (
<div data-testid="install-from-marketplace">
<button type="button" onClick={() => onSuccess()}>complete-featured-install</button>
<button type="button" onClick={onClose}>cancel-featured-install</button>
</div>
),
}))
vi.mock('@/utils/var', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/var')>()
return {
...actual,
getMarketplaceUrl: () => 'https://marketplace.test/tools',
}
})
const createTool = (
name: string,
label: string,
description = `${label} description`,
): Tool => ({
name,
author: 'author',
label: {
en_US: label,
zh_Hans: label,
},
description: {
en_US: description,
zh_Hans: description,
},
parameters: [],
labels: [],
output_schema: {},
})
const createToolProvider = (
overrides: Partial<ToolWithProvider> = {},
): ToolWithProvider => ({
id: 'provider-1',
name: 'provider-one',
author: 'Provider Author',
description: {
en_US: 'Provider description',
zh_Hans: 'Provider description',
},
icon: 'icon',
icon_dark: 'icon-dark',
label: {
en_US: 'Provider One',
zh_Hans: 'Provider One',
},
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'plugin-1',
tools: [createTool('tool-a', 'Tool A')],
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
plugin_unique_identifier: 'plugin-1@1.0.0',
...overrides,
})
const createToolValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({
provider_name: 'provider-a',
tool_name: 'tool-a',
tool_label: 'Tool A',
...overrides,
})
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'org',
author: 'author',
name: 'Plugin One',
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' },
brief: { en_US: 'Brief' },
description: { en_US: 'Plugin description' },
introduction: 'Intro',
repository: 'https://example.com',
category: PluginCategoryEnum.tool,
install_count: 0,
endpoint: { settings: [] },
tags: [{ name: 'tag-a' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const builtInTools = [
createToolProvider({
id: 'built-in-1',
name: 'built-in-provider',
label: { en_US: 'Built-in Provider', zh_Hans: 'Built-in Provider' },
tools: [createTool('built-in-tool', 'Built-in Tool')],
}),
]
const customTools = [
createToolProvider({
id: 'custom-1',
name: 'custom-provider',
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
type: CollectionType.custom,
tools: [createTool('weather-tool', 'Weather Tool')],
}),
]
const workflowTools = [
createToolProvider({
id: 'workflow-1',
name: 'workflow-provider',
label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' },
type: CollectionType.workflow,
tools: [createTool('workflow-tool', 'Workflow Tool')],
}),
]
const mcpTools = [
createToolProvider({
id: 'mcp-1',
name: 'mcp-provider',
label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' },
type: CollectionType.mcp,
tools: [createTool('mcp-tool', 'MCP Tool')],
}),
]
const renderToolPicker = (props: Partial<React.ComponentProps<typeof ToolPicker>> = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<ToolPicker
disabled={false}
trigger={<button type="button">open-picker</button>}
isShow={false}
onShowChange={vi.fn()}
onSelect={vi.fn()}
onSelectMultiple={vi.fn()}
selectedTools={[createToolValue()]}
{...props}
/>
</QueryClientProvider>,
)
}
describe('ToolPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures: {
...defaultSystemFeatures,
enable_marketplace: true,
},
setSystemFeatures: mockSetSystemFeatures,
}))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
mockUseTags.mockReturnValue({
tags: [{ name: 'weather', label: 'Weather' }],
tagsMap: { weather: { name: 'weather', label: 'Weather' } },
getTagLabel: (name: string) => name,
})
mockUseMarketplacePlugins.mockReturnValue({
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,
} as ReturnType<typeof useMarketplacePlugins>)
mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<typeof useAllBuiltInTools>)
mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType<typeof useAllCustomTools>)
mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType<typeof useAllWorkflowTools>)
mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType<typeof useAllMCPTools>)
mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools)
mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools)
mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools)
mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools)
mockUseFeaturedToolsRecommendations.mockReturnValue({
plugins: [],
isLoading: false,
} as ReturnType<typeof useFeaturedToolsRecommendations>)
mockCreateCustomCollection.mockResolvedValue(undefined)
mockInstallPackageFromMarketPlace.mockResolvedValue({
all_installed: true,
task_id: 'task-1',
})
mockCheckInstalled.mockReturnValue({
installedInfo: undefined,
isLoading: false,
error: undefined,
})
window.localStorage.clear()
})
it('should request opening when the trigger is clicked unless the picker is disabled', async () => {
const user = userEvent.setup()
const onShowChange = vi.fn()
const disabledOnShowChange = vi.fn()
renderToolPicker({ onShowChange })
await user.click(screen.getByRole('button', { name: 'open-picker' }))
expect(onShowChange).toHaveBeenCalledWith(true)
renderToolPicker({
disabled: true,
onShowChange: disabledOnShowChange,
})
await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!)
expect(disabledOnShowChange).not.toHaveBeenCalled()
})
it('should render real search and tool lists, then forward tool selections', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onSelectMultiple = vi.fn()
const queryPluginsWithDebounced = vi.fn()
mockUseMarketplacePlugins.mockReturnValue({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
} as ReturnType<typeof useMarketplacePlugins>)
renderToolPicker({
isShow: true,
scope: 'custom',
onSelect,
onSelectMultiple,
selectedTools: [],
})
expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
expect(screen.getByText('MCP Provider')).toBeInTheDocument()
await user.type(screen.getByRole('textbox'), 'weather')
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({
query: 'weather',
tags: [],
category: PluginCategoryEnum.tool,
})
})
await waitFor(() => {
expect(screen.getByText('Weather Tool')).toBeInTheDocument()
})
await user.click(screen.getByText('Weather Tool'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
provider_name: 'custom-provider',
tool_name: 'weather-tool',
tool_label: 'Weather Tool',
}))
await user.hover(screen.getByText('Custom Provider'))
await user.click(screen.getByText('workflow.tabs.addAll'))
expect(onSelectMultiple).toHaveBeenCalledWith([
expect.objectContaining({
provider_name: 'custom-provider',
tool_name: 'weather-tool',
tool_label: 'Weather Tool',
}),
])
})
it('should create a custom collection from the add button and refresh custom tools', async () => {
const user = userEvent.setup()
const { container } = renderToolPicker({
isShow: true,
supportAddCustomTool: true,
})
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
return button.className.includes('bg-components-button-primary-bg')
})
expect(addCustomToolButton).toBeTruthy()
await user.click(addCustomToolButton!)
expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-custom-tool' }))
await waitFor(() => {
expect(mockCreateCustomCollection).toHaveBeenCalledWith({ name: 'collection-a' })
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.api.actionSuccess',
})
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('edit-custom-tool-modal')).not.toBeInTheDocument()
})
it('should invalidate all tool collections after featured install succeeds', async () => {
const user = userEvent.setup()
mockUseFeaturedToolsRecommendations.mockReturnValue({
plugins: [createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' })],
isLoading: false,
} as ReturnType<typeof useFeaturedToolsRecommendations>)
renderToolPicker({
isShow: true,
selectedTools: [],
})
const featuredPluginItem = await screen.findByText('Plugin One')
await user.hover(featuredPluginItem)
await user.click(screen.getByRole('button', { name: 'plugin.installAction' }))
expect(await screen.findByTestId('install-from-marketplace')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'complete-featured-install' }))
await waitFor(() => {
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1)
expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1)
}, { timeout: 3000 })
})
})

View File

@ -0,0 +1,91 @@
import type { Node } from '../../types'
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { BlockEnum } from '../../types'
import DatasetsDetailProvider from '../provider'
import { useDatasetsDetailStore } from '../store'
const mockFetchDatasets = vi.fn()
vi.mock('@/service/datasets', () => ({
fetchDatasets: (params: unknown) => mockFetchDatasets(params),
}))
const Consumer = () => {
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
return <div>{`dataset-count:${datasetCount}`}</div>
}
const createWorkflowNode = (datasetIds: string[] = []): Node => ({
id: `node-${datasetIds.join('-') || 'empty'}`,
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Knowledge',
desc: '',
type: BlockEnum.KnowledgeRetrieval,
dataset_ids: datasetIds,
},
} as unknown as Node)
const createDataset = (id: string): DataSet => ({
id,
name: `Dataset ${id}`,
} as DataSet)
describe('datasets-detail-store provider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchDatasets.mockResolvedValue({ data: [] })
})
it('should provide the datasets detail store without fetching when no knowledge datasets are selected', () => {
render(
<DatasetsDetailProvider nodes={[
{
id: 'node-start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
} as unknown as Node,
]}
>
<Consumer />
</DatasetsDetailProvider>,
)
expect(screen.getByText('dataset-count:0')).toBeInTheDocument()
expect(mockFetchDatasets).not.toHaveBeenCalled()
})
it('should fetch unique dataset details from knowledge retrieval nodes and store them', async () => {
mockFetchDatasets.mockResolvedValue({
data: [createDataset('dataset-1'), createDataset('dataset-2')],
})
render(
<DatasetsDetailProvider nodes={[
createWorkflowNode(['dataset-1', 'dataset-2']),
createWorkflowNode(['dataset-2']),
]}
>
<Consumer />
</DatasetsDetailProvider>,
)
await waitFor(() => {
expect(mockFetchDatasets).toHaveBeenCalledWith({
url: '/datasets',
params: {
page: 1,
ids: ['dataset-1', 'dataset-2'],
},
})
expect(screen.getByText('dataset-count:2')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,308 @@
import type { Shape } from '../../store/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import HeaderInNormal from '../header-in-normal'
import HeaderInRestoring from '../header-in-restoring'
import HeaderInHistory from '../header-in-view-history'
const mockUseNodes = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleNodeSelect = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockCloseAllInputFieldPanels = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockNotify = vi.fn()
const mockRunAndHistory = vi.fn()
const mockViewHistory = vi.fn()
let mockNodesReadOnly = false
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('reactflow', () => ({
useNodes: () => mockUseNodes(),
}))
vi.mock('../../hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }),
useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }),
useWorkflowRun: () => ({
handleBackupDraft: mockHandleBackupDraft,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: vi.fn(),
}),
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../../base/toast', () => ({
default: {
notify: (payload: unknown) => mockNotify(payload),
},
}))
vi.mock('../editing-title', () => ({
default: () => <div>editing-title</div>,
}))
vi.mock('../scroll-to-selected-node-button', () => ({
default: () => <div>scroll-button</div>,
}))
vi.mock('../env-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
}))
vi.mock('../global-variable-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>,
}))
vi.mock('../run-and-history', () => ({
default: (props: object) => {
mockRunAndHistory(props)
return <div data-testid="run-and-history" />
},
}))
vi.mock('../version-history-button', () => ({
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" onClick={onClick}>
version-history
</button>
),
}))
vi.mock('../restoring-title', () => ({
default: () => <div>restoring-title</div>,
}))
vi.mock('../running-title', () => ({
default: () => <div>running-title</div>,
}))
vi.mock('../view-history', () => ({
default: (props: { withText?: boolean }) => {
mockViewHistory(props)
return <div data-testid="view-history">{props.withText ? 'with-text' : 'icon-only'}</div>
},
}))
const createSelectedNode = (selected = true) => ({
id: 'node-selected',
data: {
selected,
},
})
const createBackupDraft = (): NonNullable<Shape['backupDraft']> => ({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
environmentVariables: [],
})
const createCurrentVersion = (): NonNullable<Shape['currentVersion']> => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'Tester',
email: 'tester@example.com',
},
hash: 'hash-1',
updated_at: 0,
updated_by: {
id: 'user-1',
name: 'Tester',
email: 'tester@example.com',
},
tool_published: false,
environment_variables: [],
version: WorkflowVersion.Latest,
marked_name: '',
marked_comment: '',
})
describe('Header layout components', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
mockTheme = 'light'
mockUseNodes.mockReturnValue([])
mockRestoreWorkflow.mockResolvedValue(undefined)
})
describe('HeaderInNormal', () => {
it('should render slots, pass read-only state to action buttons, and start restoring mode', () => {
mockNodesReadOnly = true
mockUseNodes.mockReturnValue([createSelectedNode()])
const { store } = renderWorkflowComponent(
<HeaderInNormal
components={{
left: <div>left-slot</div>,
middle: <div>middle-slot</div>,
chatVariableTrigger: <div>chat-trigger</div>,
}}
/>,
{
initialStoreState: {
showEnvPanel: true,
showDebugAndPreviewPanel: true,
showVariableInspectPanel: true,
showChatVariablePanel: true,
showGlobalVariablePanel: true,
},
},
)
expect(screen.getByText('editing-title')).toBeInTheDocument()
expect(screen.getByText('scroll-button')).toBeInTheDocument()
expect(screen.getByText('left-slot')).toBeInTheDocument()
expect(screen.getByText('middle-slot')).toBeInTheDocument()
expect(screen.getByText('chat-trigger')).toBeInTheDocument()
expect(screen.getByTestId('env-button')).toHaveTextContent('true')
expect(screen.getByTestId('global-variable-button')).toHaveTextContent('true')
expect(mockRunAndHistory).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'version-history' }))
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-selected', true)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
expect(store.getState().isRestoring).toBe(true)
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(store.getState().showVariableInspectPanel).toBe(false)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
})
})
describe('HeaderInRestoring', () => {
it('should cancel restoring mode and reopen the editor state', () => {
const { store } = renderWorkflowComponent(
<HeaderInRestoring />,
{
initialStoreState: {
isRestoring: true,
showWorkflowVersionHistoryPanel: true,
},
hooksStoreProps: {
configsMap: {
flowType: FlowType.appFlow,
flowId: 'flow-1',
fileSettings: {},
},
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' }))
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().isRestoring).toBe(false)
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
})
it('should restore the selected version, clear backup state, and forward lifecycle callbacks', async () => {
const onRestoreSettled = vi.fn()
const deleteAllInspectVars = vi.fn()
const currentVersion = createCurrentVersion()
const { store } = renderWorkflowComponent(
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
{
initialStoreState: {
isRestoring: true,
showWorkflowVersionHistoryPanel: true,
backupDraft: createBackupDraft(),
currentVersion,
deleteAllInspectVars,
},
hooksStoreProps: {
configsMap: {
flowType: FlowType.appFlow,
flowId: 'flow-1',
fileSettings: {},
},
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
expect(store.getState().isRestoring).toBe(false)
expect(store.getState().backupDraft).toBeUndefined()
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
expect(deleteAllInspectVars).toHaveBeenCalledTimes(1)
expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.versionHistory.action.restoreSuccess',
})
})
expect(onRestoreSettled).toHaveBeenCalledTimes(1)
})
})
describe('HeaderInHistory', () => {
it('should render the history trigger with text and return to edit mode', () => {
const { store } = renderWorkflowComponent(
<HeaderInHistory viewHistoryProps={{ historyUrl: '/history' } as never} />,
{
initialStoreState: {
historyWorkflowData: {
id: 'history-1',
} as Shape['historyWorkflowData'],
},
},
)
expect(screen.getByText('running-title')).toBeInTheDocument()
expect(screen.getByTestId('view-history')).toHaveTextContent('with-text')
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.goBackToEdit' }))
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().historyWorkflowData).toBeUndefined()
expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({
withText: true,
}))
})
})
})

View File

@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from '../index'
let mockPathname = '/apps/demo/workflow'
let mockMaximizeCanvas = false
let mockWorkflowMode = {
normal: true,
restoring: false,
viewHistory: false,
}
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
vi.mock('../../hooks', () => ({
useWorkflowMode: () => mockWorkflowMode,
}))
vi.mock('../../store', () => ({
useStore: <T,>(selector: (state: { maximizeCanvas: boolean }) => T) => selector({
maximizeCanvas: mockMaximizeCanvas,
}),
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
vi.mock('../header-in-normal', () => ({
default: () => <div data-testid="header-normal">normal-layout</div>,
}))
vi.mock('../header-in-view-history', () => ({
default: () => <div data-testid="header-history">history-layout</div>,
}))
vi.mock('../header-in-restoring', () => ({
default: () => <div data-testid="header-restoring">restoring-layout</div>,
}))
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/apps/demo/workflow'
mockMaximizeCanvas = false
mockWorkflowMode = {
normal: true,
restoring: false,
viewHistory: false,
}
})
it('should render the normal layout and show the maximize spacer on workflow canvases', () => {
mockMaximizeCanvas = true
const { container } = render(<Header />)
expect(screen.getByTestId('header-normal')).toBeInTheDocument()
expect(screen.queryByTestId('header-history')).not.toBeInTheDocument()
expect(screen.queryByTestId('header-restoring')).not.toBeInTheDocument()
expect(container.querySelector('.h-14.w-\\[52px\\]')).not.toBeNull()
})
it('should switch between history and restoring layouts and skip the spacer outside canvas routes', async () => {
mockPathname = '/apps/demo/logs'
mockWorkflowMode = {
normal: false,
restoring: true,
viewHistory: true,
}
const { container } = render(<Header />)
expect(await screen.findByTestId('header-history')).toBeInTheDocument()
expect(await screen.findByTestId('header-restoring')).toBeInTheDocument()
expect(screen.queryByTestId('header-normal')).not.toBeInTheDocument()
expect(container.querySelector('.h-14.w-\\[52px\\]')).toBeNull()
})
})

View File

@ -0,0 +1,73 @@
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'react'
import { HooksStoreContext, HooksStoreContextProvider } from '../provider'
const mockRefreshAll = vi.fn()
const mockStore = {
getState: () => ({
refreshAll: mockRefreshAll,
}),
}
let mockReactflowState = {
d3Selection: null as object | null,
d3Zoom: null as object | null,
}
vi.mock('reactflow', () => ({
useStore: (selector: (state: typeof mockReactflowState) => unknown) => selector(mockReactflowState),
}))
vi.mock('../store', async () => {
const actual = await vi.importActual<typeof import('../store')>('../store')
return {
...actual,
createHooksStore: vi.fn(() => mockStore),
}
})
const Consumer = () => {
const store = useContext(HooksStoreContext)
return <div>{store ? 'has-hooks-store' : 'missing-hooks-store'}</div>
}
describe('hooks-store provider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReactflowState = {
d3Selection: null,
d3Zoom: null,
}
})
it('should provide the hooks store context without refreshing when the canvas handles are missing', () => {
render(
<HooksStoreContextProvider>
<Consumer />
</HooksStoreContextProvider>,
)
expect(screen.getByText('has-hooks-store')).toBeInTheDocument()
expect(mockRefreshAll).not.toHaveBeenCalled()
})
it('should refresh the hooks store when both d3Selection and d3Zoom are available', async () => {
const handleRun = vi.fn()
mockReactflowState = {
d3Selection: {},
d3Zoom: {},
}
render(
<HooksStoreContextProvider handleRun={handleRun}>
<Consumer />
</HooksStoreContextProvider>,
)
await waitFor(() => {
expect(mockRefreshAll).toHaveBeenCalledWith({
handleRun,
})
})
})
})

View File

@ -0,0 +1,107 @@
import type { ReactElement } from 'react'
import type { Node as WorkflowNode } from '../../types'
import { render, screen } from '@testing-library/react'
import { CUSTOM_NODE } from '../../constants'
import { BlockEnum } from '../../types'
import CustomNode, { Panel } from '../index'
vi.mock('../components', () => ({
NodeComponentMap: {
[BlockEnum.Start]: () => <div>start-node-component</div>,
},
PanelComponentMap: {
[BlockEnum.Start]: () => <div>start-panel-component</div>,
},
}))
vi.mock('../_base/node', () => ({
__esModule: true,
default: ({
id,
data,
children,
}: {
id: string
data: { type: BlockEnum }
children: ReactElement
}) => (
<div>
<div>{`base-node:${id}:${data.type}`}</div>
{children}
</div>
),
}))
vi.mock('../_base/components/workflow-panel', () => ({
__esModule: true,
default: ({
id,
data,
children,
}: {
id: string
data: { type: BlockEnum }
children: ReactElement
}) => (
<div>
<div>{`base-panel:${id}:${data.type}`}</div>
{children}
</div>
),
}))
const createNodeData = (): WorkflowNode['data'] => ({
title: 'Start',
desc: '',
type: BlockEnum.Start,
})
const baseNodeProps = {
type: CUSTOM_NODE,
selected: false,
zIndex: 1,
xPos: 0,
yPos: 0,
dragging: false,
isConnectable: true,
}
describe('workflow nodes index', () => {
it('should render the mapped node inside the base node shell', () => {
render(
<CustomNode
id="node-1"
data={createNodeData()}
{...baseNodeProps}
/>,
)
expect(screen.getByText('base-node:node-1:start')).toBeInTheDocument()
expect(screen.getByText('start-node-component')).toBeInTheDocument()
})
it('should render the mapped panel inside the base panel shell for custom nodes', () => {
render(
<Panel
type={CUSTOM_NODE}
id="node-1"
data={createNodeData()}
/>,
)
expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument()
expect(screen.getByText('start-panel-component')).toBeInTheDocument()
})
it('should return null for non-custom panel types', () => {
const { container } = render(
<Panel
type="default"
id="node-1"
data={createNodeData()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -0,0 +1,226 @@
import type { UploadFileSetting } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useFileUploadConfig } from '@/service/use-common'
import { TransferMethod } from '@/types/app'
import FileTypeItem from '../file-type-item'
import FileUploadSetting from '../file-upload-setting'
const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig)
const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit)
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(),
}))
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFileSizeLimit: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: vi.fn(),
close: vi.fn(),
}),
}))
const createPayload = (overrides: Partial<UploadFileSetting> = {}): UploadFileSetting => ({
allowed_file_upload_methods: [TransferMethod.local_file],
max_length: 2,
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['pdf'],
...overrides,
})
describe('File upload support components', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType<typeof useFileUploadConfig>)
mockUseFileSizeLimit.mockReturnValue({
imgSizeLimit: 10 * 1024 * 1024,
docSizeLimit: 20 * 1024 * 1024,
audioSizeLimit: 30 * 1024 * 1024,
videoSizeLimit: 40 * 1024 * 1024,
maxFileUploadLimit: 10,
} as ReturnType<typeof useFileSizeLimit>)
})
describe('FileTypeItem', () => {
it('should render built-in file types and toggle the selected type on click', () => {
const onToggle = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.image}
selected={false}
onToggle={onToggle}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument()
expect(screen.getByText('JPG, JPEG, PNG, GIF, WEBP, SVG')).toBeInTheDocument()
fireEvent.click(screen.getByText('appDebug.variableConfig.file.image.name'))
expect(onToggle).toHaveBeenCalledWith(SupportUploadFileTypes.image)
})
it('should render the custom tag editor and emit custom extensions', async () => {
const user = userEvent.setup()
const onCustomFileTypesChange = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected
onToggle={vi.fn()}
customFileTypes={['json']}
onCustomFileTypesChange={onCustomFileTypesChange}
/>,
)
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
await user.type(input, 'csv')
fireEvent.blur(input)
expect(screen.getByText('json')).toBeInTheDocument()
expect(onCustomFileTypesChange).toHaveBeenCalledWith(['json', 'csv'])
})
})
describe('FileUploadSetting', () => {
it('should update file types, upload methods, and upload limits', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FileUploadSetting
payload={createPayload()}
isMultiple
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.image.name'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image],
}))
await user.click(screen.getByText('URL'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.remote_url],
}))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } })
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
max_length: 5,
}))
})
it('should toggle built-in and custom file type selections', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.document.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.custom],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
})
it('should support both upload methods and update custom extensions', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.both'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
await user.type(input, 'csv')
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_extensions: ['pdf', 'csv'],
}))
})
it('should render support file types in the feature panel and hide them when requested', () => {
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
onChange={vi.fn()}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument()
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
hideSupportFileType
onChange={vi.fn()}
/>,
)
expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,250 @@
import type { NodeProps } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import DefaultValue from '../default-value'
import ErrorHandleOnNode from '../error-handle-on-node'
import ErrorHandleOnPanel from '../error-handle-on-panel'
import ErrorHandleTip from '../error-handle-tip'
import ErrorHandleTypeSelector from '../error-handle-type-selector'
import FailBranchCard from '../fail-branch-card'
import { useDefaultValue, useErrorHandle } from '../hooks'
import { ErrorHandleTypeEnum } from '../types'
const { mockDocLink } = vi.hoisted(() => ({
mockDocLink: vi.fn((path: string) => `https://docs.example.com${path}`),
}))
vi.mock('@/context/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/i18n')>()
return {
...actual,
useDocLink: () => mockDocLink,
}
})
vi.mock('../hooks', () => ({
useDefaultValue: vi.fn(),
useErrorHandle: vi.fn(),
}))
vi.mock('../../node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />,
}))
const mockUseDefaultValue = vi.mocked(useDefaultValue)
const mockUseErrorHandle = vi.mocked(useErrorHandle)
const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly
const baseData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
title: 'Code',
desc: '',
type: 'code' as CommonNodeType['type'],
...overrides,
})
const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => (
<ErrorHandleOnNode id={id} data={data} />
)
const renderErrorHandleNode = (data: CommonNodeType) =>
renderWorkflowFlowComponent(<div />, {
nodes: [createNode({
id: 'node-1',
type: 'errorHandleNode',
data,
})],
edges: [],
reactFlowProps: {
nodeTypes: {
errorHandleNode: ErrorHandleNodeHarness,
},
},
})
describe('error-handle path', () => {
beforeAll(() => {
class MockDOMMatrixReadOnly {
inverse() {
return this
}
transformPoint(point: { x: number, y: number }) {
return point
}
}
Object.defineProperty(window, 'DOMMatrixReadOnly', {
configurable: true,
writable: true,
value: MockDOMMatrixReadOnly,
})
})
beforeEach(() => {
vi.clearAllMocks()
mockDocLink.mockImplementation((path: string) => `https://docs.example.com${path}`)
mockUseDefaultValue.mockReturnValue({
handleFormChange: vi.fn(),
})
mockUseErrorHandle.mockReturnValue({
collapsed: false,
setCollapsed: vi.fn(),
handleErrorHandleTypeChange: vi.fn(),
})
})
afterAll(() => {
Object.defineProperty(window, 'DOMMatrixReadOnly', {
configurable: true,
writable: true,
value: originalDOMMatrixReadOnly,
})
})
// The error-handle leaf components should expose selectable strategies and contextual help.
describe('Leaf Components', () => {
it('should render the fail-branch card with the resolved learn-more link', () => {
render(<FailBranchCard />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
})
it('should render string forms and surface array forms in the default value editor', () => {
const onFormChange = vi.fn()
render(
<DefaultValue
forms={[
{ key: 'message', type: VarType.string, value: 'hello' },
{ key: 'items', type: VarType.arrayString, value: '["a"]' },
]}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated' } })
expect(onFormChange).toHaveBeenCalledWith({
key: 'message',
type: VarType.string,
value: 'updated',
})
expect(screen.getByText('items')).toBeInTheDocument()
})
it('should toggle the selector popup and report the selected strategy', async () => {
const user = userEvent.setup()
const onSelected = vi.fn()
render(
<ErrorHandleTypeSelector
value={ErrorHandleTypeEnum.none}
onSelected={onSelected}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title'))
expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue)
})
it('should render the error tip only when a strategy exists', () => {
const { rerender, container } = render(<ErrorHandleTip />)
expect(container).toBeEmptyDOMElement()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument()
})
})
// The container components should show the correct branch card or default-value editor and propagate actions.
describe('Containers', () => {
it('should render the fail-branch panel body when the strategy is active', () => {
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
})
it('should render the default-value panel body and delegate form updates', () => {
const handleFormChange = vi.fn()
mockUseDefaultValue.mockReturnValue({ handleFormChange })
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
default_value: [{ key: 'answer', type: VarType.string, value: 'draft' }],
})}
/>,
)
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'next' } })
expect(handleFormChange).toHaveBeenCalledWith(
{ key: 'answer', type: VarType.string, value: 'next' },
expect.objectContaining({ error_strategy: ErrorHandleTypeEnum.defaultValue }),
)
})
it('should hide the panel body when the hook reports a collapsed section', () => {
mockUseErrorHandle.mockReturnValue({
collapsed: true,
setCollapsed: vi.fn(),
handleErrorHandleTypeChange: vi.fn(),
})
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument()
})
it('should render the default-value node badge', () => {
renderWorkflowFlowComponent(
<ErrorHandleOnNode
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
})}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
})
it('should render the fail-branch node badge when the node throws an exception', () => {
const { container } = renderErrorHandleNode(baseData({
error_strategy: ErrorHandleTypeEnum.failBranch,
_runningStatus: NodeRunningStatus.Exception,
}))
return waitFor(() => {
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
})
})
})
})

View File

@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
import Add from '../add'
import InputField from '../index'
describe('InputField', () => {
@ -14,5 +15,12 @@ describe('InputField', () => {
expect(screen.getAllByText('input field')).toHaveLength(2)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the standalone add action button', () => {
const { container } = render(<Add />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
})

View File

@ -1,13 +1,47 @@
import { render, screen } from '@testing-library/react'
import { BoxGroupField, FieldTitle } from '../index'
import userEvent from '@testing-library/user-event'
import { Box, BoxGroup, BoxGroupField, Field, Group, GroupField } from '../index'
describe('layout index', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The barrel exports should compose the public layout primitives without extra wrappers.
// The layout primitives should preserve their composition contracts and collapse behavior.
describe('Rendering', () => {
it('should render Box and Group with optional border styles', () => {
render(
<div>
<Box withBorderBottom className="box-test">Box content</Box>
<Group withBorderBottom className="group-test">Group content</Group>
</div>,
)
expect(screen.getByText('Box content')).toHaveClass('border-b', 'box-test')
expect(screen.getByText('Group content')).toHaveClass('border-b', 'group-test')
})
it('should render BoxGroup and GroupField with nested children', () => {
render(
<div>
<BoxGroup>Inside box group</BoxGroup>
<GroupField
fieldProps={{
fieldTitleProps: {
title: 'Grouped field',
},
}}
>
Group field body
</GroupField>
</div>,
)
expect(screen.getByText('Inside box group')).toBeInTheDocument()
expect(screen.getByText('Grouped field')).toBeInTheDocument()
expect(screen.getByText('Group field body')).toBeInTheDocument()
})
it('should render BoxGroupField from the barrel export', () => {
render(
<BoxGroupField
@ -25,10 +59,23 @@ describe('layout index', () => {
expect(screen.getByText('Body content')).toBeInTheDocument()
})
it('should render FieldTitle from the barrel export', () => {
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
it('should collapse and expand Field children when supportCollapse is enabled', async () => {
const user = userEvent.setup()
render(
<Field
supportCollapse
fieldTitleProps={{ title: 'Advanced' }}
>
<div>Extra details</div>
</Field>,
)
expect(screen.getByText('Advanced')).toBeInTheDocument()
expect(screen.getByText('Extra details')).toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.queryByText('Extra details')).not.toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.getByText('Extra details')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,114 @@
import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { render } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import MixedVariableTextInput from '../index'
let capturedPromptEditorProps: PromptEditorProps[] = []
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({
editable,
value,
workflowVariableBlock,
onChange,
}: PromptEditorProps) => {
capturedPromptEditorProps.push({
editable,
value,
onChange,
workflowVariableBlock,
})
return (
<div data-testid="prompt-editor">
<div data-testid="editable-flag">{editable ? 'editable' : 'readonly'}</div>
<div data-testid="value-flag">{value || 'empty'}</div>
<button type="button" onClick={() => onChange?.('updated text')}>trigger-change</button>
</div>
)
},
}))
describe('MixedVariableTextInput', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedPromptEditorProps = []
})
it('should pass workflow variable metadata to the prompt editor and include system variables for start nodes', () => {
const nodesOutputVars: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Question Node',
vars: [],
}]
const availableNodes: Node[] = [
{
id: 'start-node',
position: { x: 0, y: 0 },
data: {
title: 'Start Node',
desc: 'Start description',
type: BlockEnum.Start,
},
},
{
id: 'llm-node',
position: { x: 120, y: 0 },
data: {
title: 'LLM Node',
desc: 'LLM description',
type: BlockEnum.LLM,
},
},
]
render(
<MixedVariableTextInput
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>,
)
const latestProps = capturedPromptEditorProps.at(-1)
expect(latestProps?.editable).toBe(true)
expect(latestProps?.workflowVariableBlock?.variables).toHaveLength(1)
expect(latestProps?.workflowVariableBlock?.workflowNodesMap).toEqual({
'start-node': {
title: 'Start Node',
type: 'start',
},
'sys': {
title: 'workflow.blocks.start',
type: 'start',
},
'llm-node': {
title: 'LLM Node',
type: 'llm',
},
})
})
it('should forward read-only state, current value, and change callbacks', async () => {
const onChange = vi.fn()
const { findByRole, getByTestId } = render(
<MixedVariableTextInput
readOnly
value="seed value"
onChange={onChange}
/>,
)
expect(getByTestId('editable-flag')).toHaveTextContent('readonly')
expect(getByTestId('value-flag')).toHaveTextContent('seed value')
const changeButton = await findByRole('button', { name: 'trigger-change' })
changeButton.click()
expect(onChange).toHaveBeenCalledWith('updated text')
})
})

View File

@ -0,0 +1,78 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
import Placeholder from '../placeholder'
const mockEditorUpdate = vi.fn((callback: () => void) => callback())
const mockDispatchCommand = vi.fn()
const mockInsertNodes = vi.fn()
const mockTextNode = vi.fn()
const mockEditor = {
update: mockEditorUpdate,
dispatchCommand: mockDispatchCommand,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
vi.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: vi.fn(),
}))
vi.mock('lexical', () => ({
$insertNodes: vi.fn(),
FOCUS_COMMAND: 'focus-command',
}))
vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({
CustomTextNode: class MockCustomTextNode {
value: string
constructor(value: string) {
this.value = value
mockTextNode(value)
}
},
}))
describe('Mixed variable placeholder', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue)
vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes))
})
it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => {
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Placeholder />
</div>,
)
fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))
expect(parentClick).not.toHaveBeenCalled()
expect(mockTextNode).toHaveBeenCalledWith('')
expect(mockInsertNodes).toHaveBeenCalledTimes(1)
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
})
it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => {
render(<Placeholder />)
const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2')
const event = createEvent.mouseDown(shortcut)
fireEvent(shortcut, event)
expect(event.defaultPrevented).toBe(true)
expect(mockTextNode).toHaveBeenCalledWith('/')
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
})
})

View File

@ -0,0 +1,268 @@
/* eslint-disable ts/no-explicit-any */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useIsChatMode,
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import { FlowType } from '@/types/common'
import ChangeBlock from '../change-block'
import PanelOperatorPopup from '../panel-operator-popup'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
<div>
<div>{trigger()}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useIsChatMode: vi.fn(),
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
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 mockUseHooksStore = vi.mocked(useHooksStore)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
describe('panel-operator details', () => {
const handleNodeChange = vi.fn()
const handleNodeDelete = vi.fn()
const handleNodesDuplicate = vi.fn()
const handleNodeSelect = vi.fn()
const handleNodesCopy = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValue(false)
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({
handleNodeChange,
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn(),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
} as ReturnType<typeof useNodesSyncDraft>)
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
})
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
describe('Internal Actions', () => {
it('should select a replacement block through ChangeBlock', async () => {
const user = userEvent.setup()
render(
<ChangeBlock
nodeId="node-1"
nodeData={{ type: BlockEnum.Code } as any}
sourceHandle="source"
/>,
)
await user.click(screen.getByText('select-http'))
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:')).toBeInTheDocument()
expect(screen.getByText('force-start:false')).toBeInTheDocument()
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
})
it('should expose trigger and start-node specific block selector options', () => {
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValueOnce(true)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValueOnce([] as any)
const { rerender } = render(
<ChangeBlock
nodeId="trigger-node"
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
})),
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
} as ReturnType<typeof useAvailableBlocks>)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
rerender(
<ChangeBlock
nodeId="start-node"
nodeData={{ type: BlockEnum.Start } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:code')).toBeInTheDocument()
expect(screen.getByText('show-start:false')).toBeInTheDocument()
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
expect(screen.getByText('force-start:true')).toBeInTheDocument()
})
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
const user = userEvent.setup()
renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink
/>,
{
nodes: [],
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
},
)
await user.click(screen.getByText('workflow.panel.runThisStep'))
await user.click(screen.getByText('workflow.common.copy'))
await user.click(screen.getByText('workflow.common.duplicate'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
})
it('should render workflow-tool and readonly popup variants', () => {
mockUseAllWorkflowTools.mockReturnValueOnce({
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
} as any)
const { rerender } = renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-2"
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: true,
isSingleton: true,
isUndeletable: true,
description: 'Read only node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
rerender(
<PanelOperatorPopup
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SupportVarInput from '../index'
describe('SupportVarInput', () => {
it('should render plain text, highlighted variables, and preserved line breaks', () => {
render(<SupportVarInput value={'Hello {{user_name}}\nWorld'} />)
expect(screen.getByText('World').closest('[title]')).toHaveAttribute('title', 'Hello {{user_name}}\nWorld')
expect(screen.getByText('user_name')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.getByText('World')).toBeInTheDocument()
})
it('should show the focused child content and call onFocus when activated', async () => {
const user = userEvent.setup()
const onFocus = vi.fn()
render(
<SupportVarInput
isFocus
value="draft"
onFocus={onFocus}
>
<input aria-label="inline-editor" />
</SupportVarInput>,
)
const editor = screen.getByRole('textbox', { name: 'inline-editor' })
expect(editor).toBeInTheDocument()
expect(screen.queryByTitle('draft')).not.toBeInTheDocument()
await user.click(editor)
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should keep the static preview visible when the input is read-only', () => {
render(
<SupportVarInput
isFocus
readonly
value="readonly content"
>
<input aria-label="hidden-editor" />
</SupportVarInput>,
)
expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument()
expect(screen.getByTitle('readonly content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,72 @@
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { VarType } from '@/app/components/workflow/types'
import AssignedVarReferencePopup from '../assigned-var-reference-popup'
const mockVarReferenceVars = vi.fn()
vi.mock('../var-reference-vars', () => ({
default: ({
vars,
onChange,
itemWidth,
isSupportFileVar,
}: {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number
isSupportFileVar?: boolean
}) => {
mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar })
return <div data-testid="var-reference-vars">{vars.length}</div>
},
}))
const createOutputVar = (overrides: Partial<NodeOutPutVar> = {}): NodeOutPutVar => ({
nodeId: 'node-1',
title: 'Node One',
vars: [{
variable: 'answer',
type: VarType.string,
}],
...overrides,
})
describe('AssignedVarReferencePopup', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty state when there are no assigned variables', () => {
render(
<AssignedVarReferencePopup
vars={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('workflow.nodes.assigner.noAssignedVars')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.assignedVarsDescription')).toBeInTheDocument()
expect(screen.queryByTestId('var-reference-vars')).not.toBeInTheDocument()
})
it('should delegate populated variable lists to the variable picker with file support enabled', () => {
const onChange = vi.fn()
render(
<AssignedVarReferencePopup
vars={[createOutputVar()]}
itemWidth={280}
onChange={onChange}
/>,
)
expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1')
expect(mockVarReferenceVars).toHaveBeenCalledWith({
vars: [createOutputVar()],
onChange,
itemWidth: 280,
isSupportFileVar: true,
})
})
})

View File

@ -1,6 +1,11 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VariableLabelInNode, VariableLabelInText } from '../index'
import VariableIcon from '../base/variable-icon'
import VariableLabel from '../base/variable-label'
import VariableName from '../base/variable-name'
import VariableNodeLabel from '../base/variable-node-label'
import { VariableIconWithColor, VariableLabelInEditor, VariableLabelInNode, VariableLabelInSelect, VariableLabelInText } from '../index'
describe('variable-label index', () => {
beforeEach(() => {
@ -39,5 +44,96 @@ describe('variable-label index', () => {
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
})
it('should render the select variant with the full variable path', () => {
render(
<VariableLabelInSelect
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload', 'answer']}
/>,
)
expect(screen.getByText('payload.answer')).toBeInTheDocument()
})
it('should render the editor variant with selected styles and inline error feedback', async () => {
const user = userEvent.setup()
const { container } = render(
<VariableLabelInEditor
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload']}
isSelected
errorMsg="Invalid variable"
rightSlot={<span>suffix</span>}
/>,
)
const badge = screen.getByText('payload').closest('div')
expect(badge).toBeInTheDocument()
expect(screen.getByText('suffix')).toBeInTheDocument()
await user.hover(screen.getByText('payload'))
expect(container.querySelector('[data-icon="Warning"]')).not.toBeNull()
})
it('should render the icon helpers for environment and exception variables', () => {
const { container } = render(
<div>
<VariableIcon variables={['env', 'API_KEY']} />
<VariableIconWithColor
variables={['conversation', 'message']}
isExceptionVariable
/>
</div>,
)
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
})
it('should render the base variable name with shortened path and title', () => {
render(
<VariableName
variables={['node-id', 'payload', 'answer']}
notShowFullPath
/>,
)
expect(screen.getByText('answer')).toHaveAttribute('title', 'answer')
})
it('should render the base node label only when node type exists', () => {
const { container, rerender } = render(<VariableNodeLabel />)
expect(container).toBeEmptyDOMElement()
rerender(
<VariableNodeLabel
nodeType={BlockEnum.Code}
nodeTitle="Code Node"
/>,
)
expect(screen.getByText('Code Node')).toBeInTheDocument()
})
it('should render the base label with variable type and right slot', () => {
render(
<VariableLabel
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['sys', 'query']}
variableType={VarType.string}
rightSlot={<span>slot</span>}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('slot')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,340 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AgentNodeType } from '../types'
import type { StrategyParamItem } from '@/app/components/plugins/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { BlockEnum } from '@/app/components/workflow/types'
import { VarType as ToolVarType } from '../../tool/types'
import { ModelBar } from '../components/model-bar'
import { ToolIcon } from '../components/tool-icon'
import Node from '../node'
import Panel from '../panel'
import { AgentFeature } from '../types'
import useConfig from '../use-config'
let mockTextGenerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockModerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockRerankModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockSpeech2TextModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockTextEmbeddingModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockTtsModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockBuiltInTools: Array<any> | undefined = []
let mockCustomTools: Array<any> | undefined = []
let mockWorkflowTools: Array<any> | undefined = []
let mockMcpTools: Array<any> | undefined = []
let mockMarketplaceIcon: string | Record<string, string> | undefined
const mockResetEditor = vi.fn()
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: (modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textGeneration)
return { data: mockTextGenerationModels }
if (modelType === ModelTypeEnum.moderation)
return { data: mockModerationModels }
if (modelType === ModelTypeEnum.rerank)
return { data: mockRerankModels }
if (modelType === ModelTypeEnum.speech2text)
return { data: mockSpeech2TextModels }
if (modelType === ModelTypeEnum.textEmbedding)
return { data: mockTextEmbeddingModels }
return { data: mockTtsModels }
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel, modelList }: any) => (
<div>{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: any) => <div>{`indicator:${color}`}</div>,
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),
useAllCustomTools: () => ({ data: mockCustomTools }),
useAllWorkflowTools: () => ({ data: mockWorkflowTools }),
useAllMCPTools: () => ({ data: mockMcpTools }),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ icon, background }: any) => <div>{`app-icon:${background}:${icon}`}</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
Group: () => <div>group-icon</div>,
}))
vi.mock('@/utils/get-icon', () => ({
getIconFromMarketPlace: () => mockMarketplaceIcon,
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: string) => value,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/group', () => ({
Group: ({ label, children }: any) => <div><div>{label}</div>{children}</div>,
GroupLabel: ({ className, children }: any) => <div className={className}>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({
SettingItem: ({ label, status, tooltip, children }: any) => <div>{label}:{status}:{tooltip}:{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, children }: any) => <div><div>{title}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({
AgentStrategy: ({ onStrategyChange }: any) => (
<button
type="button"
onClick={() => onStrategyChange({
agent_strategy_provider_name: 'provider/updated',
agent_strategy_name: 'updated-strategy',
agent_strategy_label: 'Updated Strategy',
agent_output_schema: { properties: { extra: { type: 'string', description: 'extra output' } } },
plugin_unique_identifier: 'provider/updated:1.0.0',
meta: { version: '2.0.0' },
})}
>
change-strategy
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
MCPToolAvailabilityProvider: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ window: { enabled: true, size: 8 }, query_prompt_template: 'history' })}>change-memory</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type, description }: any) => <div>{`${name}:${type}:${description}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({
setControlPromptEditorRerenderKey: mockResetEditor,
}),
}))
vi.mock('@/utils/plugin-version-feature', () => ({
isSupportMCP: () => true,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createStrategyParam = (
name: string,
type: FormTypeEnum,
required: boolean,
): StrategyParamItem => ({
name,
type,
required,
label: { en_US: name } as StrategyParamItem['label'],
help: { en_US: `${name} help` } as StrategyParamItem['help'],
placeholder: { en_US: `${name} placeholder` } as StrategyParamItem['placeholder'],
scope: 'global',
default: null,
options: [],
template: { enabled: false },
auto_generate: { type: 'none' },
})
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
title: 'Agent',
desc: '',
type: BlockEnum.Agent,
output_schema: {},
agent_strategy_provider_name: 'provider/agent',
agent_strategy_name: 'react',
agent_strategy_label: 'React Agent',
agent_parameters: {
modelParam: { type: ToolVarType.constant, value: { provider: 'openai', model: 'gpt-4o' } },
toolParam: { type: ToolVarType.constant, value: { provider_name: 'author/tool-a' } },
multiToolParam: { type: ToolVarType.constant, value: [{ provider_name: 'author/tool-b' }] },
},
meta: { version: '1.0.0' } as any,
plugin_unique_identifier: 'provider/agent:1.0.0',
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
setInputs: vi.fn(),
handleVarListChange: vi.fn(),
handleAddVariable: vi.fn(),
currentStrategy: {
identity: {
author: 'provider',
name: 'react',
icon: 'icon',
label: { en_US: 'React Agent' } as any,
provider: 'provider/agent',
},
parameters: [
createStrategyParam('modelParam', FormTypeEnum.modelSelector, true),
createStrategyParam('optionalModel', FormTypeEnum.modelSelector, false),
createStrategyParam('toolParam', FormTypeEnum.toolSelector, false),
createStrategyParam('multiToolParam', FormTypeEnum.multiToolSelector, false),
],
description: { en_US: 'agent description' } as any,
output_schema: {},
features: [AgentFeature.HISTORY_MESSAGES],
},
formData: {},
onFormChange: vi.fn(),
currentStrategyStatus: {
plugin: { source: 'marketplace', installed: true },
isExistInPlugin: false,
},
strategyProvider: undefined,
pluginDetail: {
declaration: {
label: 'Mock Plugin',
},
} as any,
availableVars: [],
availableNodesWithParent: [],
outputSchema: [{ name: 'jsonField', type: 'String', description: 'json output' }],
handleMemoryChange: vi.fn(),
isChatMode: true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('agent path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTextGenerationModels = [{ provider: 'openai', models: [{ model: 'gpt-4o' }] }]
mockModerationModels = []
mockRerankModels = []
mockSpeech2TextModels = []
mockTextEmbeddingModels = []
mockTtsModels = []
mockBuiltInTools = [{ name: 'author/tool-a', is_team_authorization: true, icon: 'https://example.com/icon-a.png' }]
mockCustomTools = []
mockWorkflowTools = [{ id: 'author/tool-b', is_team_authorization: false, icon: { content: 'B', background: '#fff' } }]
mockMcpTools = []
mockMarketplaceIcon = 'https://example.com/marketplace.png'
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Path Integration', () => {
it('should render model bars for missing, installed, and missing-install models', () => {
const { rerender, container } = render(<ModelBar />)
expect(container).toHaveTextContent('no-model:0')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4o" />)
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4.1" />)
expect(container).toHaveTextContent('openai/gpt-4.1:1')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
})
it('should render tool icons across loading, marketplace fallback, authorization warning, and fetch-error states', async () => {
const user = userEvent.setup()
const { unmount } = render(<ToolIcon id="tool-0" providerName="author/tool-a" />)
expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument()
fireEvent.error(screen.getByRole('img', { name: 'tool icon' }))
expect(screen.getByText('group-icon')).toBeInTheDocument()
unmount()
const secondRender = render(<ToolIcon id="tool-1" providerName="author/tool-b" />)
expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument()
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
mockBuiltInTools = undefined
secondRender.rerender(<ToolIcon id="tool-2" providerName="author/tool-c" />)
expect(screen.getByText('group-icon')).toBeInTheDocument()
mockBuiltInTools = []
secondRender.rerender(<ToolIcon id="tool-3" providerName="market/tool-d" />)
expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument()
await user.unhover(screen.getByRole('img', { name: 'tool icon' }))
})
it('should render strategy, models, and toolbox entries in the node', () => {
const { container } = render(
<Node
id="agent-node"
data={createData()}
/>,
)
expect(screen.getByText(/workflow\.nodes\.agent\.strategy\.shortLabel/)).toBeInTheDocument()
expect(container).toHaveTextContent('React Agent')
expect(screen.getByText('workflow.nodes.agent.model')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.agent.toolbox')).toBeInTheDocument()
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
})
it('should render the panel, update the selected strategy, and expose memory plus output vars', async () => {
const user = userEvent.setup()
const config = createConfigResult()
mockUseConfig.mockReturnValue(config)
render(
<Panel
id="agent-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument()
expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument()
expect(screen.getByText('jsonField:String:json output')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'change-strategy' }))
expect(config.setInputs).toHaveBeenCalledWith(expect.objectContaining({
agent_strategy_provider_name: 'provider/updated',
agent_strategy_name: 'updated-strategy',
agent_strategy_label: 'Updated Strategy',
plugin_unique_identifier: 'provider/updated:1.0.0',
}))
expect(mockResetEditor).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'change-memory' }))
expect(config.handleMemoryChange).toHaveBeenCalledWith({
window: { enabled: true, size: 8 },
query_prompt_template: 'history',
})
})
})
})

View File

@ -0,0 +1,514 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import OperationSelector from '../components/operation-selector'
import VarList from '../components/var-list'
import Node from '../node'
import Panel from '../panel'
import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types'
import useConfig from '../use-config'
const mockHandleAddOperationItem = vi.fn()
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({
default: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => (
<div>
<div>{Array.isArray(value) ? value.join('.') : String(value ?? '')}</div>
{valueTypePlaceHolder && <div>{`type:${valueTypePlaceHolder}`}</div>}
{popupFor === 'toAssigned' && (
<div>{`filter:${String(filterVar?.({ nodeId: 'node-1', variable: 'count', type: VarType.string }))}:${String(filterVar?.({ nodeId: 'node-2', variable: 'other', type: VarType.string }))}`}</div>
)}
<button
type="button"
onClick={() => {
onOpen?.()
onChange(popupFor === 'assigned' ? ['node-1', 'count'] : ['node-2', 'result'])
}}
>
{placeholder || popupFor || 'pick-var'}
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange }: any) => (
<textarea
aria-label="code-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/bool-value', () => ({
default: ({ value, onChange }: any) => (
<button type="button" onClick={() => onChange(!value)}>
{`bool:${String(value)}`}
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables, nodeTitle, rightSlot }: any) => (
<div>
<span>{nodeTitle}</span>
<span>{variables.join('.')}</span>
{rightSlot}
</div>
),
}))
vi.mock('../hooks', () => ({
useHandleAddOperationItem: () => mockHandleAddOperationItem,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
variable_selector: ['node-1', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-2', 'result'],
...overrides,
})
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
version: '2',
items: [createOperation()],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleOperationListChanges: vi.fn(),
getAssignedVarType: vi.fn(() => VarType.string),
getToAssignedVarType: vi.fn(() => VarType.string),
writeModeTypes: [WriteMode.overwrite, WriteMode.clear, WriteMode.set],
writeModeTypesArr: [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend],
writeModeTypesNum,
filterAssignedVar: vi.fn(() => true),
filterToAssignedVar: vi.fn(() => true),
getAvailableVars: vi.fn(() => []),
filterVar: vi.fn(() => vi.fn(() => true)),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('assigner path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHandleAddOperationItem.mockReturnValue([createOperation(), createOperation({ variable_selector: [] })])
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Path Integration', () => {
it('should open the operation selector and choose number operations', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<OperationSelector
value={WriteMode.overwrite}
onSelect={onSelect}
assignedVarType={VarType.number}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={[WriteMode.increment]}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
expect(screen.getByText('workflow.nodes.assigner.operations.clear')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.set')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.+=')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.assigner.operations.+='))
expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment })
})
it('should not open a disabled operation selector', async () => {
const user = userEvent.setup()
render(
<OperationSelector
value={WriteMode.overwrite}
onSelect={vi.fn()}
disabled
assignedVarType={VarType.string}
writeModeTypes={[WriteMode.overwrite]}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
expect(screen.queryByText('workflow.nodes.assigner.operations.title')).not.toBeInTheDocument()
})
it('should render empty and populated variable lists across constant editors', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
const { rerender } = render(
<VarList
readonly={false}
nodeId="node-1"
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('workflow.nodes.assigner.noVarTip')).toBeInTheDocument()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ variable_selector: [], value: [] })]}
onChange={onChange}
onOpen={onOpen}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.string)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.selectAssignedVariable'))
expect(onOpen).toHaveBeenCalledWith(0)
expect(onChange).toHaveBeenLastCalledWith([
{
variable_selector: ['node-1', 'count'],
operation: WriteMode.overwrite,
input_type: AssignerNodeInputType.variable,
value: undefined,
},
], ['node-1', 'count'])
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.boolean)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
expect(screen.getByText('filter:false:true')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
await user.click(screen.getByText('workflow.nodes.assigner.operations.set'))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({
operation: WriteMode.set,
input_type: AssignerNodeInputType.constant,
value: false,
}),
])
onChange.mockClear()
await user.click(screen.getByText('workflow.nodes.assigner.setParameter'))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] }),
], ['node-2', 'result'])
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: 'hello' })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.string)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated text' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: 'updated text' }),
], 'updated text')
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: 3 })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.number)}
getToAssignedVarType={vi.fn(() => VarType.number)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('3'), { target: { value: '5' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: 5 }),
], 5)
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: false })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.boolean)}
getToAssignedVarType={vi.fn(() => VarType.boolean)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
await user.click(screen.getByRole('button', { name: 'bool:false' }))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: true }),
], true)
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: '{\"a\":1}' })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.object)}
getToAssignedVarType={vi.fn(() => VarType.object)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{\"a\":2}' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: '{\"a\":2}' }),
], '{"a":2}')
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.increment, value: 2 })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.number)}
getToAssignedVarType={vi.fn(() => VarType.number)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '4' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.increment, value: 4 }),
], 4)
const buttons = screen.getAllByRole('button')
await user.click(buttons.at(-1)!)
expect(onChange).toHaveBeenLastCalledWith([])
})
it('should render version 2 and legacy node previews', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={createData({
items: [createOperation({ variable_selector: [] })],
})}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.getByText('workflow.nodes.assigner.varNotSet')).toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={createData({
items: [createOperation()],
})}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: ['sys', 'query'],
write_mode: WriteMode.append,
} as any}
/>,
)
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('sys.query')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
})
it('should skip empty version 2 items and resolve system variables without an operation badge', () => {
renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={createData({
items: [
createOperation({ variable_selector: [] }),
createOperation({
variable_selector: ['sys', 'query'],
operation: undefined,
}),
],
})}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('sys.query')).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.assigner.operations.over-write')).not.toBeInTheDocument()
})
it('should return null for legacy nodes without assigned variables and resolve non-system legacy vars', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: [],
write_mode: WriteMode.append,
} as any}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.queryByText('workflow.nodes.assigner.operations.append')).not.toBeInTheDocument()
expect(screen.queryByText('node-1.count')).not.toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: ['node-1', 'count'],
write_mode: WriteMode.append,
} as any}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
})
it('should add panel operations with the real variable list inside the panel', async () => {
const user = userEvent.setup()
const config = createConfigResult({
inputs: createData(),
})
mockUseConfig.mockReturnValue(config)
render(
<Panel
id="assigner-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getAllByRole('button')[0])
expect(mockHandleAddOperationItem).toHaveBeenCalledWith(createData().items)
expect(config.handleOperationListChanges).toHaveBeenCalledWith([
createOperation(),
createOperation({ variable_selector: [] }),
])
expect(screen.getByText('workflow.nodes.assigner.variables')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,39 @@
import type { CodeDependency } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DependencyPicker from '../dependency-picker'
const dependencies: CodeDependency[] = [
{ name: 'numpy', version: '1.0.0' },
{ name: 'pandas', version: '2.0.0' },
]
describe('DependencyPicker', () => {
it('should open the dependency list, filter by search text, and select a new dependency', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<DependencyPicker
value={dependencies[0]!}
available_dependencies={dependencies}
onChange={onChange}
/>,
)
expect(screen.getByText('numpy')).toBeInTheDocument()
await user.click(screen.getByText('numpy'))
await user.type(screen.getByRole('textbox'), 'pan')
expect(screen.getByRole('textbox')).toHaveValue('pan')
expect(screen.getByText('pandas')).toBeInTheDocument()
await user.click(screen.getByText('pandas'))
expect(onChange).toHaveBeenCalledWith(dependencies[1])
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,204 @@
import type { ReactNode } from 'react'
import type { DocExtractorNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LanguagesSupported } from '@/i18n-config/language'
import { BlockEnum } from '../../../types'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
let mockLocale = 'en-US'
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useNodes: () => [
{
id: 'node-1',
data: {
title: 'Input Files',
type: BlockEnum.Start,
},
},
],
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({
variables,
nodeTitle,
nodeType,
}: {
variables: string[]
nodeTitle?: string
nodeType?: BlockEnum
}) => <div>{`${nodeTitle}:${nodeType}:${variables.join('.')}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: string[]) => void
}) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({
useNodeHelpLink: () => 'https://docs.example.com/document-extractor',
}))
vi.mock('@/service/use-common', () => ({
useFileSupportTypes: () => ({
data: {
allowed_extensions: ['PDF', 'md', 'md', 'DOCX'],
},
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
title: 'Document Extractor',
desc: '',
type: BlockEnum.DocExtractor,
variable_selector: ['node-1', 'files'],
is_array_file: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleVarChanges: vi.fn(),
filterVar: () => true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('document-extractor path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should render nothing when the node input variable is not configured', () => {
const { container } = render(
<Node
id="doc-node"
data={createData({
variable_selector: [],
})}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render the selected input variable on the node', () => {
render(
<Node
id="doc-node"
data={createData()}
/>,
)
expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument()
expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument()
})
it('should wire panel input changes and format supported file types for english locales', async () => {
const user = userEvent.setup()
const handleVarChanges = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_array_file: false,
}),
handleVarChanges,
}))
render(
<Panel
id="doc-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-file-var' }))
expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files'])
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' })).toHaveAttribute(
'href',
'https://docs.example.com/document-extractor',
)
expect(screen.getByText('text:string')).toBeInTheDocument()
})
it('should use chinese separators and array output types when the input is an array of files', () => {
mockLocale = LanguagesSupported[1]
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_array_file: true,
}),
}))
render(
<Panel
id="doc-node"
data={createData({
is_array_file: true,
})}
panelProps={panelProps}
/>,
)
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}')).toBeInTheDocument()
expect(screen.getByText('text:array[string]')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,705 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { KeyValue as HttpKeyValue, HttpNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import ApiInput from '../components/api-input'
import AuthorizationModal from '../components/authorization'
import RadioGroup from '../components/authorization/radio-group'
import EditBody from '../components/edit-body'
import KeyValue from '../components/key-value'
import BulkEdit from '../components/key-value/bulk-edit'
import KeyValueEdit from '../components/key-value/key-value-edit'
import InputItem from '../components/key-value/key-value-edit/input-item'
import KeyValueItem from '../components/key-value/key-value-edit/item'
import Timeout from '../components/timeout'
import Node from '../node'
import Panel from '../panel'
import { AuthorizationType, BodyType, Method } from '../types'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: vi.fn((_nodeId: string, options?: any) => ({
availableVars: [
{ variable: ['node-1', 'token'], type: VarType.string },
{ variable: ['node-1', 'upload'], type: VarType.file },
].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true),
availableNodes: [],
availableNodesWithParent: [],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({ value, onChange, placeholder, className, readOnly, onFocusChange }: any) => (
<input
value={value}
placeholder={placeholder}
className={className}
readOnly={readOnly}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange, filterVar, onRemove }: any) => (
<div>
<div>{`file-filter:${String(filterVar?.({ type: VarType.file }))}:${String(filterVar?.({ type: VarType.string }))}`}</div>
<button type="button" onClick={() => onChange(['node-1', 'file'])}>pick-file</button>
{onRemove && <button type="button" onClick={onRemove}>remove-file</button>}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: ({ value, onChange, title }: any) => (
<div>
<div>{typeof title === 'string' ? title : 'editor'}</div>
<input value={value} onChange={event => onChange(event.target.value)} />
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/text-editor', () => ({
default: ({ value, onChange, onBlur, headerRight }: any) => (
<div>
{headerRight}
<textarea value={value} onChange={event => onChange(event.target.value)} onBlur={onBlur} />
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
default: ({ value }: any) => <div>{value}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({
default: ({ options, onChange, trigger }: any) => (
<div>
{trigger}
{options.map((option: any) => (
<button key={option.value} type="button" onClick={() => onChange(option.value)}>
{option.label}
</button>
))}
</div>
),
}))
vi.mock('../components/curl-panel', () => ({
default: () => <div>curl-panel</div>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const mockUseStore = vi.mocked(useStore)
const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
title: 'HTTP Request',
desc: '',
type: BlockEnum.HttpRequest,
variables: [],
method: Method.get,
url: 'https://api.example.com',
authorization: { type: AuthorizationType.none },
headers: '',
params: '',
body: { type: BodyType.none, data: [] },
timeout: { connect: 5, read: 10, write: 15 },
ssl_verify: true,
...overrides,
})
const keyValueItem: HttpKeyValue = {
id: 'kv-1',
key: 'name',
value: 'alice',
type: 'text',
}
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
isDataReady: true,
inputs: createData(),
handleVarListChange: vi.fn(),
handleAddVariable: vi.fn(),
filterVar: vi.fn(() => true),
handleMethodChange: vi.fn(),
handleUrlChange: vi.fn(),
headers: [keyValueItem],
setHeaders: vi.fn(),
addHeader: vi.fn(),
isHeaderKeyValueEdit: false,
toggleIsHeaderKeyValueEdit: vi.fn(),
params: [keyValueItem],
setParams: vi.fn(),
addParam: vi.fn(),
isParamKeyValueEdit: false,
toggleIsParamKeyValueEdit: vi.fn(),
setBody: vi.fn(),
handleSSLVerifyChange: vi.fn(),
isShowAuthorization: true,
showAuthorization: vi.fn(),
hideAuthorization: vi.fn(),
setAuthorization: vi.fn(),
setTimeout: vi.fn(),
isShowCurlPanel: true,
showCurlPanel: vi.fn(),
hideCurlPanel: vi.fn(),
handleCurlImport: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: HttpNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('http path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseStore.mockReturnValue({
HttpRequest: {
timeout: {
max_connect_timeout: 10,
max_read_timeout: 600,
max_write_timeout: 600,
},
},
} as any)
mockUseConfig.mockReturnValue(createConfigResult())
})
// The HTTP path should expose auth, request editing, key-value tables, timeout, and request preview behavior.
describe('Path Integration', () => {
it('should switch radio-group options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<RadioGroup
options={[
{ value: 'none', label: 'None' },
{ value: 'apiKey', label: 'API Key' },
]}
value="none"
onChange={onChange}
/>,
)
await user.click(screen.getByText('API Key'))
expect(onChange).toHaveBeenCalledWith('apiKey')
})
it('should edit authorization settings and save them', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onHide = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'apiKey', config: { type: 'custom', header: 'X-Key', api_key: 'secret' } } as any}
onChange={onChange}
isShow
onHide={onHide}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
fireEvent.change(screen.getByDisplayValue('secret'), { target: { value: 'updated-secret' } })
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalled()
expect(onHide).toHaveBeenCalled()
})
it('should bootstrap api key config when auth starts without config', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'none' as any }}
onChange={onChange}
isShow
onHide={vi.fn()}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'api-key',
config: expect.objectContaining({
type: 'basic',
api_key: '',
}),
}))
})
it('should create custom header auth config and apply focus styles to the api key input', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'api-key' as any }}
onChange={onChange}
isShow
onHide={vi.fn()}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0] as HTMLInputElement, { target: { value: 'X-Token' } })
fireEvent.focus(inputs[1] as HTMLInputElement)
expect(inputs[1]).toHaveClass('border-components-input-border-active')
fireEvent.change(inputs[1] as HTMLInputElement, { target: { value: 'secret-token' } })
fireEvent.blur(inputs[1] as HTMLInputElement)
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'api-key',
config: expect.objectContaining({
type: 'custom',
header: 'X-Token',
api_key: 'secret-token',
}),
}))
})
it('should update method and url from the api input', async () => {
const user = userEvent.setup()
const onMethodChange = vi.fn()
const onUrlChange = vi.fn()
render(
<ApiInput
nodeId="node-1"
readonly={false}
method={'GET' as any}
onMethodChange={onMethodChange}
url="https://api.example.com"
onUrlChange={onUrlChange}
/>,
)
await user.click(screen.getByText('POST'))
fireEvent.change(screen.getByDisplayValue('https://api.example.com'), { target: { value: 'https://api.changed.com' } })
expect(onMethodChange).toHaveBeenCalled()
expect(onUrlChange).toHaveBeenCalledWith('https://api.changed.com')
})
it('should hide the method dropdown icon and use an empty placeholder in readonly mode', () => {
const { container } = render(
<ApiInput
nodeId="node-1"
readonly
method={'GET' as any}
onMethodChange={vi.fn()}
url="https://api.example.com"
onUrlChange={vi.fn()}
/>,
)
expect(container.querySelector('svg')).toBeNull()
expect(screen.getByDisplayValue('https://api.example.com')).toHaveAttribute('placeholder', '')
})
it('should update focus styling for editable inputs and show the remove action again on blur', () => {
const onChange = vi.fn()
const onRemove = vi.fn()
const { container, rerender } = render(
<InputItem
nodeId="node-1"
value="alice"
onChange={onChange}
hasRemove
onRemove={onRemove}
/>,
)
const input = screen.getByDisplayValue('alice')
fireEvent.focus(input)
expect(input).toHaveClass('bg-components-input-bg-active')
expect(container.querySelector('button')).toBeNull()
fireEvent.blur(input)
expect(container.querySelector('button')).not.toBeNull()
rerender(
<InputItem
nodeId="node-1"
value=""
onChange={onChange}
hasRemove={false}
placeholder="missing-value"
readOnly
/>,
)
expect(screen.getByText('missing-value')).toBeInTheDocument()
})
it('should clamp timeout values and propagate changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Timeout
readonly={false}
nodeId="node-1"
payload={{ connect: 5, read: 10, write: 15 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '999' } })
expect(onChange).toHaveBeenCalled()
})
it('should clear timeout values to undefined and clamp low values to the minimum', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Timeout
readonly={false}
nodeId="node-1"
payload={{ connect: 5, read: 10, write: 15 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
fireEvent.change(screen.getByDisplayValue('10'), { target: { value: '' } })
fireEvent.change(screen.getByDisplayValue('15'), { target: { value: '0' } })
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ read: undefined }))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ write: 1 }))
})
it('should delegate key-value list editing and bulk editing actions', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onAdd = vi.fn()
render(
<div>
<KeyValue
readonly={false}
nodeId="node-1"
list={[keyValueItem]}
onChange={onChange}
onAdd={onAdd}
/>
<BulkEdit
value="name:alice"
onChange={onChange}
onSwitchToKeyValueEdit={onAdd}
/>
</div>,
)
fireEvent.change(screen.getAllByDisplayValue('name:alice')[0], { target: { value: 'name:bob' } })
fireEvent.blur(screen.getAllByDisplayValue('name:bob')[0])
await user.click(screen.getByText('workflow.nodes.http.keyValueEdit'))
expect(onChange).toHaveBeenCalled()
expect(onAdd).toHaveBeenCalled()
})
it('should return null when key-value edit receives a non-array list', () => {
const { container } = render(
<KeyValueEdit
readonly={false}
nodeId="node-1"
list={'invalid' as any}
onChange={vi.fn()}
onAdd={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should edit standalone input items and key-value rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
const onAdd = vi.fn()
render(
<div>
<InputItem
nodeId="node-1"
value="alice"
onChange={onChange}
hasRemove
onRemove={onRemove}
/>
<KeyValueItem
instanceId="kv-1"
nodeId="node-1"
readonly={false}
canRemove
payload={keyValueItem}
onChange={onChange}
onRemove={onRemove}
isLastItem
onAdd={onAdd}
isSupportFile
/>
<KeyValueEdit
readonly={false}
nodeId="node-1"
list={[keyValueItem]}
onChange={onChange}
onAdd={onAdd}
/>
</div>,
)
fireEvent.change(screen.getAllByDisplayValue('alice')[0], { target: { value: 'bob' } })
await user.click(screen.getByText('text'))
await user.click(screen.getByText('file'))
expect(onChange).toHaveBeenCalled()
})
it('should edit key-only rows and select file payload rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<KeyValueItem
instanceId="kv-2"
nodeId="node-1"
readonly={false}
canRemove
payload={{ id: 'kv-2', key: 'attachment', value: '', type: 'file', file: [] } as any}
onChange={onChange}
onRemove={onRemove}
isLastItem={false}
onAdd={vi.fn()}
isSupportFile
keyNotSupportVar
/>,
)
fireEvent.change(screen.getByDisplayValue('attachment'), { target: { value: 'upload' } })
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
await user.click(screen.getByText('pick-file'))
await user.click(screen.getByText('remove-file'))
expect(onChange).toHaveBeenCalled()
expect(onRemove).toHaveBeenCalled()
})
it('should update the raw-text body payload', () => {
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'raw-text', data: [{ id: 'body-1', type: 'text', value: 'hello' }] } as any}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated-body' } })
expect(onChange).toHaveBeenCalled()
})
it('should initialize an empty json body and support legacy string payload rendering', () => {
const onChange = vi.fn()
const { rerender } = render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'json', data: [] } as any}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '{"a":1}' } })
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'json',
data: [expect.objectContaining({ value: '{"a":1}' })],
}))
rerender(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'json', data: 'legacy' } as any}
onChange={onChange}
/>,
)
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('should switch to key-value body types and propagate key-value edits', () => {
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'none', data: [] } as any}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('radio', { name: 'form-data' }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'form-data',
data: [expect.objectContaining({ key: '', value: '' })],
}))
onChange.mockClear()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'form-data', data: [{ id: 'body-1', type: 'text', key: 'name', value: 'alice' }] } as any}
onChange={onChange}
/>,
)
fireEvent.click(screen.getAllByDisplayValue('alice')[0]!)
fireEvent.change(screen.getAllByDisplayValue('alice')[0]!, { target: { value: 'bob' } })
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data.length === 2)).toBe(true)
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data[0]?.value === 'bob')).toBe(true)
})
it('should render the binary body picker and forward file selections', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'binary', data: [{ id: 'body-1', type: 'file', file: [] }] } as any}
onChange={onChange}
/>,
)
await user.click(screen.getByText('pick-file'))
expect(onChange).toHaveBeenCalled()
})
it('should initialize an empty binary body before saving the selected file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'binary', data: [] } as any}
onChange={onChange}
/>,
)
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
await user.click(screen.getByText('pick-file'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'binary',
data: [expect.objectContaining({
type: 'file',
file: ['node-1', 'file'],
})],
}))
})
it('should render the request node preview when a url exists', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData()}
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText(Method.get)).toBeInTheDocument()
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render nothing when the request url is empty', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({ url: '' })}
/>,
{ nodes: [], edges: [] },
)
expect(screen.queryByText(Method.get)).not.toBeInTheDocument()
expect(screen.queryByText('https://api.example.com')).not.toBeInTheDocument()
})
it('should render the panel sections and output vars', async () => {
renderPanel()
expect(screen.getByText('body:string')).toBeInTheDocument()
expect(screen.getByText('status_code:number')).toBeInTheDocument()
expect(screen.getByText('headers:object')).toBeInTheDocument()
expect(screen.getByText('files:Array[File]')).toBeInTheDocument()
expect(screen.getAllByText('workflow.nodes.http.authorization.authorization').length).toBeGreaterThan(0)
expect(screen.getByText('workflow.nodes.http.curl.title')).toBeInTheDocument()
expect(screen.getByText('curl-panel')).toBeInTheDocument()
})
it('should hide modal overlays when the panel is readonly', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
readOnly: true,
}))
renderPanel()
expect(screen.queryByText('curl-panel')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.http.authorization.api-key-title')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,430 @@
import type { Var } from '../../../types'
import type { IfElseNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
BlockEnum,
VarType,
} from '../../../types'
import { VarType as NumberVarType } from '../../tool/types'
import ConditionAdd from '../components/condition-add'
import ConditionFilesListValue from '../components/condition-files-list-value'
import ConditionList from '../components/condition-list'
import ConditionOperator from '../components/condition-list/condition-operator'
import ConditionNumberInput from '../components/condition-number-input'
import ConditionValue from '../components/condition-value'
import Node from '../node'
import Panel from '../panel'
import {
ComparisonOperator,
LogicalOperator,
} from '../types'
import useConfig from '../use-config'
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useNodes: () => [
{
id: 'node-1',
data: {
title: 'Start Node',
type: BlockEnum.Start,
},
},
],
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'score'], { type: VarType.number })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInText: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
VariableLabelInNode: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div data-testid={`handle-${handleId}`} />,
}))
const mockWorkflowStoreState = {
controlPromptEditorRerenderKey: 0,
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState),
useWorkflowStore: () => ({
getState: () => ({
...mockWorkflowStoreState,
conversationVariables: [],
dataSourceList: [],
setControlPromptEditorRerenderKey: vi.fn(),
}),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/use-match-schema-type', () => ({
__esModule: true,
default: () => ({
schemaTypeDefinitions: [],
matchSchemaType: () => undefined,
}),
}))
vi.mock('../../variable-assigner/hooks', () => ({
useGetAvailableVars: () => () => [
{
variable: ['node-1', 'score'],
type: VarType.number,
},
],
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType => ({
title: 'If Else',
desc: '',
type: BlockEnum.IfElse,
isInIteration: false,
isInLoop: false,
cases: [
{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
},
],
},
],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterVar: () => true,
filterNumberVar: (varPayload: Var) => varPayload.type === VarType.number,
handleAddCase: vi.fn(),
handleRemoveCase: vi.fn(),
handleSortCase: vi.fn(),
handleAddCondition: vi.fn(),
handleUpdateCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleAddSubVariableCondition: vi.fn(),
handleRemoveSubVariableCondition: vi.fn(),
handleUpdateSubVariableCondition: vi.fn(),
handleToggleSubVariableConditionLogicalOperator: vi.fn(),
nodesOutputVars: [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'answer',
type: VarType.string,
},
],
},
],
availableNodes: [],
nodesOutputNumberVars: [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'score',
type: VarType.number,
},
],
},
],
availableNumberNodes: [],
varsIsVarFileAttribute: {},
...overrides,
})
const baseNodeProps = {
type: 'custom',
selected: false,
zIndex: 1,
xPos: 0,
yPos: 0,
dragging: false,
isConnectable: true,
}
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('if-else path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreState.controlPromptEditorRerenderKey = 0
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Condition controls', () => {
it('should add a condition variable from the selector', async () => {
const user = userEvent.setup()
const onSelectVariable = vi.fn()
render(
<ConditionAdd
caseId="case-1"
variables={[]}
onSelectVariable={onSelectVariable}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.ifElse.addCondition/i }))
await user.click(screen.getByText('pick-var'))
expect(onSelectVariable).toHaveBeenCalledWith('case-1', ['node-1', 'score'], { type: VarType.number })
})
it('should switch operators and number input modes', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onNumberVarTypeChange = vi.fn()
const onValueChange = vi.fn()
render(
<div>
<ConditionOperator
varType={VarType.string}
value={ComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionNumberInput
value="12"
numberVarType={NumberVarType.constant}
onNumberVarTypeChange={onNumberVarTypeChange}
onValueChange={onValueChange}
variables={[]}
unit="%"
/>
</div>,
)
await user.click(screen.getByRole('button', { name: /contains/i }))
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByRole('button', { name: /constant/i }))
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})
it('should toggle logical operators for a case list with multiple conditions', async () => {
const user = userEvent.setup()
const onToggleConditionLogicalOperator = vi.fn()
render(
<ConditionList
caseId="case-1"
caseItem={{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
},
{
id: 'condition-2',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.is,
value: 'world',
},
],
}}
nodeId="node-1"
nodesOutputVars={[]}
availableNodes={[]}
numberVariables={[]}
filterVar={() => true}
varsIsVarFileAttribute={{}}
onToggleConditionLogicalOperator={onToggleConditionLogicalOperator}
/>,
)
await user.click(screen.getByText('AND'))
expect(onToggleConditionLogicalOperator).toHaveBeenCalledWith('case-1')
})
})
describe('Display rendering', () => {
it('should render formatted condition values and file sub-conditions', () => {
render(
<div>
<ConditionValue
variableSelector={['node-1', 'answer']}
operator={ComparisonOperator.contains}
value="{{#node-1.answer#}}"
/>
<ConditionFilesListValue
condition={{
id: 'condition-files',
varType: VarType.object,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
case_id: 'sub-case',
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'sub-condition',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: 'report',
},
],
},
}}
/>
</div>,
)
expect(screen.getByText('node-1.answer')).toBeInTheDocument()
expect(screen.getByText('{{answer}}')).toBeInTheDocument()
expect(screen.getByText('node-1.files')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('report')).toBeInTheDocument()
})
it('should render node cases, missing setup state, and else handles', () => {
render(
<Node
id="if-else-node"
{...baseNodeProps}
data={createData({
cases: [
{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-empty',
varType: VarType.string,
variable_selector: [],
comparison_operator: ComparisonOperator.contains,
value: '',
},
],
},
{
case_id: 'case-2',
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'condition-ready',
varType: VarType.boolean,
variable_selector: ['node-1', 'passed'],
comparison_operator: ComparisonOperator.is,
value: false,
},
],
},
],
})}
/>,
)
expect(screen.getByText('IF')).toBeInTheDocument()
expect(screen.getByText('ELIF')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.ifElse.conditionNotSetup')).toBeInTheDocument()
expect(screen.getByText('False')).toBeInTheDocument()
expect(screen.getByText('ELSE')).toBeInTheDocument()
expect(screen.getByTestId('handle-case-1')).toBeInTheDocument()
expect(screen.getByTestId('handle-case-2')).toBeInTheDocument()
expect(screen.getByTestId('handle-false')).toBeInTheDocument()
})
})
describe('Panel integration', () => {
it('should add a case from the panel action and render else description', async () => {
const user = userEvent.setup()
const handleAddCase = vi.fn()
const inputs = createData({ cases: [] })
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs,
handleAddCase,
}))
render(
<Panel
id="if-else-node"
data={inputs}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: /elif/i }))
expect(handleAddCase).toHaveBeenCalled()
expect(screen.getByText('workflow.nodes.ifElse.elseDescription')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,266 @@
import type { ReactNode } from 'react'
import type { IterationNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { BlockEnum, VarType } from '../../../types'
import AddBlock from '../add-block'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
const mockHandleNodeAdd = vi.fn()
const mockHandleNodeIterationRerender = vi.fn()
let mockNodesReadOnly = false
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
Background: ({ id }: { id: string }) => <div data-testid={id} />,
useViewport: () => ({ zoom: 1 }),
useNodesInitialized: () => true,
}
})
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
trigger,
onSelect,
availableBlocksTypes = [],
disabled,
}: {
trigger?: (open: boolean) => ReactNode
onSelect?: (type: BlockEnum) => void
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
}) => (
<div>
{trigger ? <div>{trigger(false)}</div> : null}
<button
type="button"
disabled={disabled}
onClick={() => onSelect?.(availableBlocksTypes[0] ?? BlockEnum.Code)}
>
select-block
</button>
</div>
),
}))
vi.mock('../../iteration-start', () => ({
IterationStartNodeDumb: () => <div>iteration-start-node</div>,
}))
vi.mock('../use-interactions', () => ({
useNodeIterationInteractions: () => ({
handleNodeIterationRerender: mockHandleNodeIterationRerender,
}),
}))
vi.mock('../../../hooks', () => ({
useAvailableBlocks: () => ({
availableNextBlocks: [BlockEnum.Code],
}),
useNodesInteractions: () => ({
handleNodeAdd: mockHandleNodeAdd,
}),
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
availableVars,
}: {
onChange: (value: string[], kindType?: string, varInfo?: { type: VarType }) => void
availableVars?: unknown[]
}) => (
<button
type="button"
onClick={() => {
if (availableVars)
onChange(['child-node', 'text'], 'variable', { type: VarType.string })
else
onChange(['node-1', 'items'], 'variable', { type: VarType.arrayString })
}}
>
{availableVars ? 'pick-output-var' : 'pick-input-var'}
</button>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
const createData = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
start_node_id: 'start-node',
iterator_selector: ['node-1', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child-node', 'text'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 3,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
_isShowTips: false,
_children: [],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterInputVar: () => true,
handleInputChange: vi.fn(),
childrenNodeVars: [],
iterationChildrenNodes: [],
handleOutputVarChange: vi.fn(),
changeParallel: vi.fn(),
changeErrorResponseMode: vi.fn(),
changeParallelNums: vi.fn(),
changeFlattenOutput: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('iteration path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should add the next block from the iteration start node', async () => {
const user = userEvent.setup()
render(
<AddBlock
iterationNodeId="iteration-node"
iterationNodeData={createData()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-block' }))
expect(mockHandleNodeAdd).toHaveBeenCalledWith({
nodeType: BlockEnum.Code,
pluginDefaultValue: undefined,
}, {
prevNodeId: 'start-node',
prevNodeSourceHandle: 'source',
})
})
it('should render candidate iteration nodes and show the parallel warning once', () => {
render(
<Node
id="iteration-node"
data={createData({
_isCandidate: true,
_children: [{ nodeId: 'child-1', nodeType: BlockEnum.Iteration }],
is_parallel: true,
_isShowTips: true,
})}
/>,
)
expect(screen.getByText('iteration-start-node')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'select-block' })).toBeInTheDocument()
expect(screen.getByTestId('iteration-background-iteration-node')).toBeInTheDocument()
expect(mockHandleNodeIterationRerender).toHaveBeenCalledWith('iteration-node')
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'warning',
message: 'workflow.nodes.iteration.answerNodeWarningDesc',
duration: 5000,
})
})
it('should wire panel input, output, parallel, numeric, error mode, and flatten actions', async () => {
const user = userEvent.setup()
const handleInputChange = vi.fn()
const handleOutputVarChange = vi.fn()
const changeParallel = vi.fn()
const changeParallelNums = vi.fn()
const changeErrorResponseMode = vi.fn()
const changeFlattenOutput = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_parallel: true,
flatten_output: false,
}),
handleInputChange,
handleOutputVarChange,
changeParallel,
changeParallelNums,
changeErrorResponseMode,
changeFlattenOutput,
}))
render(
<Panel
id="iteration-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-input-var' }))
await user.click(screen.getByRole('button', { name: 'pick-output-var' }))
await user.click(screen.getAllByRole('switch')[0]!)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
await user.click(screen.getByRole('button', { name: /workflow.nodes.iteration.ErrorMethod.operationTerminated/i }))
await user.click(screen.getByText('workflow.nodes.iteration.ErrorMethod.continueOnError'))
await user.click(screen.getAllByRole('switch')[1]!)
expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { type: VarType.arrayString })
expect(handleOutputVarChange).toHaveBeenCalledWith(['child-node', 'text'], 'variable', { type: VarType.string })
expect(changeParallel).toHaveBeenCalledWith(false)
expect(changeParallelNums).toHaveBeenCalledWith(7)
expect(changeErrorResponseMode).toHaveBeenCalledWith(expect.objectContaining({
value: ErrorHandleMode.ContinueOnError,
}))
expect(changeFlattenOutput).toHaveBeenCalledWith(true)
})
it('should hide parallel controls when parallel mode is disabled', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_parallel: false,
}),
}))
render(
<Panel
id="iteration-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,615 @@
import type {
ComparisonOperator,
MetadataFilteringCondition,
MetadataShape,
} from '../types'
import type { DataSet, MetadataInDoc } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ChunkingMode,
DatasetPermission,
DataSourceType,
} from '@/models/datasets'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import { DatasetsDetailContext } from '../../../datasets-detail-store/provider'
import { createDatasetsDetailStore } from '../../../datasets-detail-store/store'
import { BlockEnum, VarType } from '../../../types'
import AddDataset from '../components/add-dataset'
import DatasetItem from '../components/dataset-item'
import DatasetList from '../components/dataset-list'
import ConditionCommonVariableSelector from '../components/metadata/condition-list/condition-common-variable-selector'
import ConditionDate from '../components/metadata/condition-list/condition-date'
import ConditionItem from '../components/metadata/condition-list/condition-item'
import ConditionOperator from '../components/metadata/condition-list/condition-operator'
import ConditionValueMethod from '../components/metadata/condition-list/condition-value-method'
import ConditionVariableSelector from '../components/metadata/condition-list/condition-variable-selector'
import MetadataFilter from '../components/metadata/metadata-filter'
import MetadataFilterSelector from '../components/metadata/metadata-filter/metadata-filter-selector'
import MetadataTrigger from '../components/metadata/metadata-trigger'
import RetrievalConfig from '../components/retrieval-config'
import Node from '../node'
import {
LogicalOperator,
ComparisonOperator as MetadataComparisonOperator,
MetadataFilteringModeEnum,
MetadataFilteringVariableType,
} from '../types'
const mockHasEditPermissionForDataset = vi.fn((
_userId: string,
_datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission },
) => true)
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
const createMetadata = (overrides: Partial<MetadataInDoc> = {}): MetadataInDoc => ({
id: 'meta-1',
name: 'topic',
type: MetadataFilteringVariableType.string,
value: 'topic',
...overrides,
})
const createCondition = (overrides: Partial<MetadataFilteringCondition> = {}): MetadataFilteringCondition => ({
id: 'condition-1',
name: 'topic',
metadata_id: 'meta-1',
comparison_operator: MetadataComparisonOperator.contains,
value: 'agent',
...overrides,
})
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string } }) => unknown) => selector({
userProfile: { id: 'user-1' },
}),
useAppContext: () => ({
userProfile: {
timezone: 'UTC',
},
}),
}))
vi.mock('@/utils/permission', () => ({
hasEditPermissionForDataset: (
userId: string,
datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission },
) => mockHasEditPermissionForDataset(userId, datasetConfig),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
__esModule: true,
default: ({ onSelect, onClose }: { onSelect: (datasets: DataSet[]) => void, onClose: () => void }) => (
<div>
<button type="button" onClick={() => onSelect([createDataset({ id: 'dataset-2', name: 'Selected Dataset' })])}>
select-dataset
</button>
<button type="button" onClick={onClose}>
close-select-dataset
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/dataset-config/settings-modal', () => ({
__esModule: true,
default: ({ currentDataset, onSave, onCancel }: { currentDataset: DataSet, onSave: (dataset: DataSet) => void, onCancel: () => void }) => (
<div>
<div>{currentDataset.name}</div>
<button type="button" onClick={() => onSave(createDataset({ ...currentDataset, name: 'Updated Dataset' }))}>
save-settings
</button>
<button type="button" onClick={onCancel}>
cancel-settings
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/dataset-config/params-config/config-content', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (config: Record<string, unknown>, isRetrievalModeChange?: boolean) => void }) => (
<div>
<button
type="button"
onClick={() => onChange({
retrieval_model: RETRIEVE_TYPE.multiWay,
top_k: 8,
score_threshold_enabled: true,
score_threshold: 0.4,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-v3',
},
reranking_mode: 'weighted_score',
weights: {
weight_type: 'customized',
vector_setting: {
vector_weight: 0.7,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-3',
},
keyword_setting: {
keyword_weight: 0.3,
},
},
reranking_enable: true,
})}
>
apply-retrieval-config
</button>
<button
type="button"
onClick={() => onChange({
retrieval_model: RETRIEVE_TYPE.oneWay,
}, true)}
>
change-retrieval-mode
</button>
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div>model-parameter-modal</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'field'], { type: VarType.string })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
vi.mock('../components/metadata/metadata-panel', () => ({
__esModule: true,
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>metadata-panel</div>
<button type="button" onClick={onCancel}>
close-metadata-panel
</button>
</div>
),
}))
describe('knowledge-retrieval path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasEditPermissionForDataset.mockReturnValue(true)
})
describe('Dataset controls', () => {
it('should open dataset selector and forward selected datasets', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AddDataset
selectedIds={['dataset-1']}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('add-button'))
await user.click(screen.getByText('select-dataset'))
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
id: 'dataset-2',
name: 'Selected Dataset',
}),
])
})
it('should support editing and removing a dataset item', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<DatasetItem
payload={createDataset({ is_multimodal: true })}
onChange={onChange}
onRemove={onRemove}
/>,
)
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
const buttons = screen.getAllByRole('button')
await user.click(buttons[0]!)
await user.click(screen.getByText('save-settings'))
await user.click(buttons[1]!)
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated Dataset' }))
expect(onRemove).toHaveBeenCalled()
})
it('should render empty and populated dataset lists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<DatasetList
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('appDebug.datasetConfig.knowledgeTip')).toBeInTheDocument()
rerender(
<DatasetList
list={[createDataset()]}
onChange={onChange}
/>,
)
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
await user.click(screen.getAllByRole('button')[1]!)
expect(onChange).toHaveBeenCalledWith([])
})
})
describe('Retrieval settings', () => {
it('should open retrieval config and map config updates back to workflow payload', async () => {
const user = userEvent.setup()
const onRetrievalModeChange = vi.fn()
const onMultipleRetrievalConfigChange = vi.fn()
render(
<RetrievalConfig
payload={{
retrieval_mode: RETRIEVE_TYPE.multiWay,
multiple_retrieval_config: {
top_k: 3,
score_threshold: null,
},
}}
onRetrievalModeChange={onRetrievalModeChange}
onMultipleRetrievalConfigChange={onMultipleRetrievalConfigChange}
rerankModalOpen
onRerankModelOpenChange={vi.fn()}
selectedDatasets={[createDataset()]}
/>,
)
await user.click(screen.getByText('apply-retrieval-config'))
await user.click(screen.getByText('change-retrieval-mode'))
expect(onMultipleRetrievalConfigChange).toHaveBeenCalledWith(expect.objectContaining({
top_k: 8,
score_threshold: 0.4,
reranking_model: {
provider: 'cohere',
model: 'rerank-v3',
},
reranking_enable: true,
}))
expect(onRetrievalModeChange).toHaveBeenCalledWith(RETRIEVE_TYPE.oneWay)
})
})
describe('Metadata controls', () => {
it('should select metadata filter mode from the dropdown', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<MetadataFilterSelector
value={MetadataFilteringModeEnum.disabled}
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title/i }))
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.options.manual.title'))
expect(onSelect).toHaveBeenCalledWith(MetadataFilteringModeEnum.manual)
})
it('should remove stale metadata conditions and open the manual metadata panel', async () => {
const user = userEvent.setup()
const handleRemoveCondition = vi.fn()
render(
<MetadataTrigger
selectedDatasetsLoaded
metadataList={[createMetadata()]}
metadataFilteringConditions={{
logical_operator: LogicalOperator.and,
conditions: [
createCondition(),
createCondition({
id: 'condition-stale',
metadata_id: 'missing',
name: 'missing',
}),
],
}}
handleAddCondition={vi.fn()}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={vi.fn()}
handleUpdateCondition={vi.fn()}
/>,
)
expect(handleRemoveCondition).toHaveBeenCalledWith('condition-stale')
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i }))
expect(screen.getByText('metadata-panel')).toBeInTheDocument()
})
it('should render automatic and manual metadata filter states', async () => {
const user = userEvent.setup()
const baseProps: MetadataShape = {
metadataList: [createMetadata()],
metadataFilteringConditions: {
logical_operator: LogicalOperator.and,
conditions: [createCondition()],
},
selectedDatasetsLoaded: true,
handleAddCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleUpdateCondition: vi.fn(),
}
const { rerender } = render(
<MetadataFilter
{...baseProps}
metadataFilterMode={MetadataFilteringModeEnum.automatic}
handleMetadataFilterModeChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.automatic.title/i })).toBeInTheDocument()
rerender(
<MetadataFilter
{...baseProps}
metadataFilterMode={MetadataFilteringModeEnum.manual}
handleMetadataFilterModeChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i }))
expect(screen.getByText('metadata-panel')).toBeInTheDocument()
})
})
describe('Condition inputs', () => {
it('should toggle value method and keep the same option idempotent', async () => {
const user = userEvent.setup()
const onValueMethodChange = vi.fn()
render(
<ConditionValueMethod
valueMethod="variable"
onValueMethodChange={onValueMethodChange}
/>,
)
await user.click(screen.getByRole('button', { name: /variable/i }))
await user.click(screen.getByText('Constant'))
await user.click(screen.getByRole('button', { name: /variable/i }))
await user.click(screen.getAllByText('Variable')[1]!)
expect(onValueMethodChange).toHaveBeenCalledTimes(1)
expect(onValueMethodChange).toHaveBeenCalledWith('constant')
})
it('should select workflow and common variables', async () => {
const user = userEvent.setup()
const onVariableChange = vi.fn()
const onCommonVariableChange = vi.fn()
const { rerender } = render(
<ConditionVariableSelector
onChange={onVariableChange}
varType={VarType.string}
/>,
)
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select'))
await user.click(screen.getByText('pick-var'))
expect(onVariableChange).toHaveBeenCalledWith(['node-1', 'field'], { type: VarType.string })
rerender(
<ConditionCommonVariableSelector
variables={[{ name: 'common', type: 'string', value: 'sys.user_name' }]}
varType={VarType.string}
onChange={onCommonVariableChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select'))
await user.click(screen.getByText('sys.user_name'))
expect(onCommonVariableChange).toHaveBeenCalledWith('sys.user_name')
})
it('should update operator, clear date values, and remove conditions', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onDateChange = vi.fn()
const onRemoveCondition = vi.fn()
const onUpdateCondition = vi.fn()
const { container } = render(
<div>
<ConditionOperator
variableType={MetadataFilteringVariableType.string}
value={MetadataComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionDate
value={1710000000}
onChange={onDateChange}
/>
<ConditionItem
metadataList={[createMetadata()]}
condition={createCondition()}
onRemoveCondition={onRemoveCondition}
onUpdateCondition={onUpdateCondition}
/>
</div>,
)
await user.click(screen.getAllByRole('button', { name: /contains/i })[0]!)
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByText(/March 09 2024/).nextElementSibling as Element)
fireEvent.change(screen.getByDisplayValue('agent'), { target: { value: 'updated-agent' } })
fireEvent.click(container.querySelector('.ml-1.mt-1') as Element)
expect(onSelect).toHaveBeenCalledWith(MetadataComparisonOperator.is as ComparisonOperator)
expect(onDateChange).toHaveBeenCalledWith()
expect(onUpdateCondition).toHaveBeenCalledWith('condition-1', expect.objectContaining({ value: 'updated-agent' }))
expect(onRemoveCondition).toHaveBeenCalledWith('condition-1')
})
})
describe('Node rendering', () => {
it('should render selected datasets from the detail store and hide when none are selected', () => {
const store = createDatasetsDetailStore()
store.getState().updateDatasetsDetail([createDataset()])
const renderNode = (datasetIds: string[]) => render(
<DatasetsDetailContext.Provider value={store}>
<Node
id="knowledge-node"
data={{
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
desc: '',
dataset_ids: datasetIds,
query_variable_selector: [],
query_attachment_selector: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
}}
/>
</DatasetsDetailContext.Provider>,
)
const { rerender, container } = renderNode(['dataset-1'])
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
rerender(
<DatasetsDetailContext.Provider value={store}>
<Node
id="knowledge-node"
data={{
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
desc: '',
dataset_ids: [],
query_variable_selector: [],
query_attachment_selector: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
}}
/>
</DatasetsDetailContext.Provider>,
)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -0,0 +1,309 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { ListFilterNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import ExtractInput from '../components/extract-input'
import LimitConfig from '../components/limit-config'
import SubVariablePicker from '../components/sub-variable-picker'
import Node from '../node'
import Panel from '../panel'
import { OrderBy } from '../types'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: vi.fn((_nodeId: string, options?: any) => ({
availableVars: [
{ variable: ['node-1', 'size'], type: VarType.number },
{ variable: ['node-1', 'name'], type: VarType.string },
].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true),
availableNodesWithParent: [{ id: 'node-1', data: { title: 'Answer', type: BlockEnum.Answer } }],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({ value, onChange, placeholder, className, readOnly, onFocusChange }: any) => (
<input
value={value}
placeholder={placeholder}
className={className}
readOnly={readOnly}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slider', () => ({
default: ({ value, onChange }: any) => (
<button type="button" onClick={() => onChange(value + 1)}>
slider-{value}
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
default: ({ title, onSelect }: any) => <button type="button" onClick={onSelect}>{title}</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange(['node-1', 'items'])}>pick-var</button>,
}))
vi.mock('../components/filter-condition', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ key: 'size' })}>filter-condition</button>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNodeType => ({
title: 'List Operator',
desc: '',
type: BlockEnum.ListFilter,
variable: ['node-1', 'items'],
var_type: VarType.arrayNumber,
item_var_type: VarType.number,
filter_by: { enabled: true, conditions: [{ key: 'size', comparison_operator: 'equal', value: '1' }] as any },
extract_by: { enabled: true, serial: '1' },
limit: { enabled: true, size: 10 },
order_by: { enabled: true, key: 'size', value: OrderBy.ASC },
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterVar: vi.fn(() => true),
varType: VarType.arrayNumber,
itemVarType: VarType.number,
itemVarTypeShowName: 'number',
hasSubVariable: true,
handleVarChanges: vi.fn(),
handleFilterEnabledChange: vi.fn(),
handleFilterChange: vi.fn(),
handleLimitChange: vi.fn(),
handleOrderByEnabledChange: vi.fn(),
handleOrderByKeyChange: vi.fn(),
handleOrderByTypeChange: vi.fn(() => vi.fn()),
handleExtractsEnabledChange: vi.fn(),
handleExtractsChange: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: ListFilterNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('list-operator path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfig.mockReturnValue(createConfigResult())
})
// The list-operator path should expose extract, limit, ordering, and node variable previews.
describe('Path Integration', () => {
it('should update the extract input', async () => {
const onChange = vi.fn()
const { rerender } = render(
<ExtractInput
nodeId="node-1"
readOnly={false}
value="1"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
fireEvent.focus(screen.getByDisplayValue('1'))
expect(screen.getByDisplayValue('1')).toHaveClass('border-components-input-border-active')
rerender(
<ExtractInput
nodeId="node-1"
readOnly
value=""
onChange={onChange}
/>,
)
expect(onChange).toHaveBeenCalled()
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '')
})
it('should change the selected sub variable', async () => {
const onChange = vi.fn()
const { unmount } = render(
<SubVariablePicker
value="size"
onChange={onChange}
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
const option = await screen.findByText('name')
await act(async () => {
fireEvent.click(option)
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('name')
})
unmount()
render(
<SubVariablePicker
value=""
onChange={onChange}
/>,
)
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
})
it('should toggle limit and update the size slider', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<LimitConfig
readonly={false}
config={{ enabled: true, size: 10 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('slider-10'))
expect(onChange).toHaveBeenCalledWith({ enabled: true, size: 11 })
rerender(
<LimitConfig
readonly={false}
config={{ enabled: false, size: 10 }}
onChange={onChange}
/>,
)
expect(screen.queryByText('slider-10')).not.toBeInTheDocument()
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith({ enabled: true, size: 10 })
})
it('should render the selected input variable in the node preview', () => {
renderWorkflowFlowComponent(
<Node
id="node-2"
data={createData()}
/>,
{
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer, title: 'Answer' } as any }],
edges: [],
},
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('items')).toBeInTheDocument()
})
it('should resolve system variables through the start node and return null without a variable', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="node-2"
data={createData({ variable: ['sys', 'files'] as any })}
/>,
{
nodes: [{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start, title: 'Start' } as any }],
edges: [],
},
)
expect(screen.getByText('Start')).toBeInTheDocument()
rerender(
<Node
id="node-2"
data={createData({ variable: [] as any })}
/>,
)
expect(screen.queryByText('workflow.nodes.listFilter.inputVar')).not.toBeInTheDocument()
expect(screen.queryByText('Start')).not.toBeInTheDocument()
})
it('should render the panel controls and output vars', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByText('pick-var'))
await user.click(screen.getByText('filter-condition'))
await user.click(screen.getByText('workflow.nodes.listFilter.asc'))
expect(screen.getByText('result:Array[number]')).toBeInTheDocument()
expect(screen.getByText('first_record:number')).toBeInTheDocument()
expect(screen.getByText('last_record:number')).toBeInTheDocument()
})
it('should hide disabled sections and render order controls without sub variables', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
variable: undefined as any,
filter_by: { enabled: false, conditions: [] as any },
extract_by: { enabled: false, serial: '' },
order_by: { enabled: false, key: '', value: OrderBy.ASC },
}),
hasSubVariable: false,
}))
const { rerender } = renderPanel()
expect(screen.queryByText('filter-condition')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('1')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.listFilter.asc')).not.toBeInTheDocument()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
order_by: { enabled: true, key: '', value: OrderBy.ASC },
}),
hasSubVariable: false,
}))
rerender(<Panel id="node-1" data={createData()} panelProps={panelProps} />)
expect(screen.getByText('workflow.nodes.listFilter.asc')).toBeInTheDocument()
expect(screen.queryByText('common.placeholder.select')).not.toBeInTheDocument()
})
})
})

View File

@ -1,10 +1,8 @@
import type { LLMNodeType } from '../types'
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ProviderContextState } from '@/context/provider-context'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import { screen } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@ -12,17 +10,14 @@ import {
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useProviderContextSelector } from '@/context/provider-context'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../../../types'
import Panel from '../panel'
const mockUseConfig = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
}))
vi.mock('../use-config', () => ({
default: (...args: unknown[]) => mockUseConfig(...args),
}))
@ -31,80 +26,12 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param
default: () => <div data-testid="model-parameter-modal" />,
}))
vi.mock('../components/config-prompt', () => ({
default: () => <div data-testid="config-prompt" />,
}))
vi.mock('../../_base/components/config-vision', () => ({
default: () => null,
}))
vi.mock('../../_base/components/memory-config', () => ({
default: () => null,
}))
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
default: () => null,
}))
vi.mock('../components/reasoning-format-config', () => ({
default: () => null,
}))
vi.mock('../components/structure-output', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
VarItem: () => null,
}))
type MockUseConfigReturn = ReturnType<typeof mockUseConfig>
const modelProviderSelector = vi.mocked(useProviderContextSelector)
const createProviderContextState = (modelProviders: ModelProvider[]): ProviderContextState => ({
modelProviders,
refreshModelProviders: vi.fn(),
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: true,
enableBilling: false,
onPlanInfoChanged: vi.fn(),
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
educationAccountExpireAt: null,
isLoadingEducationAccountInfo: false,
isFetchingEducationAccountInfo: false,
webappCopyrightEnabled: false,
licenseLimit: {
workspace_members: {
size: 0,
limit: 0,
},
},
refreshLicenseLimit: vi.fn(),
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
})
const createMockModelProvider = (provider: string): ModelProvider => ({
provider,
label: { en_US: provider, zh_Hans: provider },
@ -195,21 +122,27 @@ const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
})
const renderPanel = (data?: Partial<LLMNodeType>) => {
return render(
<Panel
id="llm-node"
data={{ ...baseNodeData, ...data }}
panelProps={panelProps}
/>,
return renderWorkflowFlowComponent(
<ProviderContext.Provider value={createMockProviderContextValue({
modelProviders: [createMockModelProvider('openai')],
isFetchedPlan: true,
})}
>
<Panel
id="llm-node"
data={{ ...baseNodeData, ...data }}
panelProps={panelProps}
/>
</ProviderContext.Provider>,
{
hooksStoreProps: {},
},
)
}
describe('LLM Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
modelProviderSelector.mockImplementation(selector => selector(
createProviderContextState([createMockModelProvider('openai')]),
))
mockUseConfig.mockReturnValue(buildUseConfigResult())
})

View File

@ -0,0 +1,665 @@
import type { NodeOutPutVar } from '../../../types'
import type { Condition, LoopNodeType, LoopVariable } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ErrorHandleMode, ValueType } from '@/app/components/workflow/types'
import {
BlockEnum,
VarType,
} from '../../../types'
import { VarType as NumberVarType } from '../../tool/types'
import AddBlock from '../add-block'
import ConditionAdd from '../components/condition-add'
import ConditionFilesListValue from '../components/condition-files-list-value'
import ConditionList from '../components/condition-list'
import ConditionItem from '../components/condition-list/condition-item'
import ConditionOperator from '../components/condition-list/condition-operator'
import ConditionNumberInput from '../components/condition-number-input'
import ConditionValue from '../components/condition-value'
import LoopVariables from '../components/loop-variables'
import FormItem from '../components/loop-variables/form-item'
import InputModeSelect from '../components/loop-variables/input-mode-selec'
import VariableTypeSelect from '../components/loop-variables/variable-type-select'
import InsertBlock from '../insert-block'
import Node from '../node'
import Panel from '../panel'
import {
ComparisonOperator,
LogicalOperator,
} from '../types'
import useConfig from '../use-config'
const mockHandleNodeAdd = vi.fn()
const mockHandleNodeLoopRerender = vi.fn()
const mockToastNotify = vi.fn()
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
Background: ({ id }: { id: string }) => <div data-testid={id} />,
useViewport: () => ({ zoom: 1 }),
useNodesInitialized: () => true,
useStore: (selector: (state: { d3Selection: null, d3Zoom: null }) => unknown) => selector({
d3Selection: null,
d3Zoom: null,
}),
}
})
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
onSelect,
onOpenChange,
open,
availableBlocksTypes = [],
trigger,
disabled,
}: {
onSelect?: (type: BlockEnum) => void
onOpenChange?: (open: boolean) => void
open?: boolean
availableBlocksTypes?: BlockEnum[]
trigger?: (open: boolean) => React.ReactNode
disabled?: boolean
}) => (
<div>
{trigger ? <div>{trigger(Boolean(open))}</div> : null}
<button
type="button"
disabled={disabled}
onClick={() => {
onOpenChange?.(!open)
onSelect?.(availableBlocksTypes[0] ?? BlockEnum.LLM)
}}
>
select-block
</button>
</div>
),
}))
vi.mock('../../loop-start', () => ({
LoopStartNodeDumb: () => <div>loop-start-node</div>,
}))
vi.mock('../use-interactions', () => ({
useNodeLoopInteractions: () => ({
handleNodeLoopRerender: mockHandleNodeLoopRerender,
}),
}))
vi.mock('../../../hooks', () => ({
useAvailableBlocks: () => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.LLM],
}),
useNodesInteractions: () => ({
handleNodeAdd: mockHandleNodeAdd,
}),
useNodesReadOnly: () => ({
nodesReadOnly: false,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'score'], { type: VarType.number })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button
type="button"
onClick={() => onChange('{{#node-1.score#}}')}
>
pick-reference
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
const mockWorkflowStoreState = {
controlPromptEditorRerenderKey: 0,
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState),
useWorkflowStore: () => ({
getState: () => ({
...mockWorkflowStoreState,
conversationVariables: [],
dataSourceList: [],
setControlPromptEditorRerenderKey: vi.fn(),
}),
}),
}))
vi.mock('../../variable-assigner/hooks', () => ({
useGetAvailableVars: () => () => [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'score',
type: VarType.number,
},
],
},
],
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
__esModule: true,
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<textarea
aria-label="code-editor"
value={value}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: (payload: unknown) => mockToastNotify(payload),
},
}))
vi.mock('../../_base/components/input-number-with-slider', () => ({
__esModule: true,
default: ({ value, onChange }: { value: number, onChange: (value: number) => void }) => (
<input
aria-label="loop-count"
type="number"
value={value}
onChange={e => onChange(Number(e.target.value))}
/>
),
}))
vi.mock('../../_base/components/split', () => ({
__esModule: true,
default: ({ className }: { className?: string }) => <div data-testid="split" className={className} />,
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createCondition = (overrides: Partial<Condition> = {}): Condition => ({
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
...overrides,
})
const createLoopVariable = (overrides: Partial<LoopVariable> = {}): LoopVariable => ({
id: 'loop-var-1',
label: 'item',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'value',
...overrides,
})
const createNodeOutputVar = (vars: NodeOutPutVar['vars']): NodeOutPutVar => ({
nodeId: 'node-1',
title: 'Start Node',
vars,
})
const createData = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_id: 'loop-node',
logical_operator: LogicalOperator.and,
break_conditions: [createCondition()],
loop_count: 3,
error_handle_mode: ErrorHandleMode.ContinueOnError,
loop_variables: [createLoopVariable()],
_children: [],
isInIteration: false,
isInLoop: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterInputVar: vi.fn(() => true),
childrenNodeVars: [createNodeOutputVar([{ variable: 'answer', type: VarType.string }])],
loopChildrenNodes: [
{
id: 'node-1',
data: {
title: 'Start Node',
type: BlockEnum.Start,
},
} as ReturnType<typeof useConfig>['loopChildrenNodes'][number],
],
handleAddCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleUpdateCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleAddSubVariableCondition: vi.fn(),
handleUpdateSubVariableCondition: vi.fn(),
handleRemoveSubVariableCondition: vi.fn(),
handleToggleSubVariableConditionLogicalOperator: vi.fn(),
handleUpdateLoopCount: vi.fn(),
changeErrorResponseMode: vi.fn(),
handleAddLoopVariable: vi.fn(),
handleRemoveLoopVariable: vi.fn(),
handleUpdateLoopVariable: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('loop path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHandleNodeAdd.mockReset()
mockHandleNodeLoopRerender.mockReset()
mockWorkflowStoreState.controlPromptEditorRerenderKey = 0
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Condition controls', () => {
it('should add a condition variable from the selector', async () => {
const user = userEvent.setup()
const onSelectVariable = vi.fn()
render(
<ConditionAdd
variables={[createNodeOutputVar([{ variable: 'score', type: VarType.number }])]}
onSelectVariable={onSelectVariable}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.ifElse.addCondition/i }))
await user.click(screen.getByText('pick-var'))
expect(onSelectVariable).toHaveBeenCalledWith(['node-1', 'score'], { type: VarType.number })
})
it('should switch operators and number input modes', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onNumberVarTypeChange = vi.fn()
const onValueChange = vi.fn()
render(
<div>
<ConditionOperator
varType={VarType.string}
value={ComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionNumberInput
value="12"
numberVarType={NumberVarType.constant}
onNumberVarTypeChange={onNumberVarTypeChange}
onValueChange={onValueChange}
variables={[createNodeOutputVar([{ variable: 'score', type: VarType.number }])]}
unit="%"
/>
</div>,
)
await user.click(screen.getByRole('button', { name: /contains/i }))
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByRole('button', { name: /constant/i }))
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})
it('should toggle logical operators for a condition list with boolean conditions', async () => {
const user = userEvent.setup()
const onToggleConditionLogicalOperator = vi.fn()
render(
<ConditionList
conditions={[
createCondition({
id: 'condition-1',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: true,
}),
createCondition({
id: 'condition-2',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: false,
}),
]}
logicalOperator={LogicalOperator.and}
nodeId="loop-node"
availableNodes={[]}
numberVariables={[]}
availableVars={[]}
onToggleConditionLogicalOperator={onToggleConditionLogicalOperator}
/>,
)
await user.click(screen.getByText('AND'))
expect(onToggleConditionLogicalOperator).toHaveBeenCalled()
})
it('should render condition values, file sub-conditions, and select updates', async () => {
const onUpdateCondition = vi.fn()
const onRemoveCondition = vi.fn()
const onAddSubVariableCondition = vi.fn()
render(
<div>
<ConditionValue
variableSelector={['node-1', 'answer']}
operator={ComparisonOperator.contains}
value="{{#node-1.answer#}}"
/>
<ConditionFilesListValue
condition={{
id: 'condition-files',
varType: VarType.object,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'sub-condition',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: 'report',
},
],
},
}}
/>
<ConditionItem
conditionId="condition-select"
condition={{
id: 'condition-select',
key: 'type',
varType: VarType.string,
comparison_operator: ComparisonOperator.in,
value: ['pdf'],
}}
isSubVariableKey
nodeId="loop-node"
availableNodes={[]}
numberVariables={[]}
availableVars={[]}
onUpdateSubVariableCondition={vi.fn()}
onRemoveSubVariableCondition={vi.fn()}
onAddSubVariableCondition={onAddSubVariableCondition}
/>
<ConditionItem
conditionId="condition-string"
condition={createCondition({ id: 'condition-string', value: 'draft' })}
nodeId="loop-node"
availableNodes={[]}
numberVariables={[]}
availableVars={[]}
onUpdateCondition={onUpdateCondition}
onRemoveCondition={onRemoveCondition}
/>
</div>,
)
expect(screen.getAllByText('node-1.answer')).toHaveLength(2)
expect(screen.getByText('{{answer}}')).toBeInTheDocument()
expect(screen.getByText('node-1.files')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('report')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(onUpdateCondition).not.toHaveBeenCalled()
expect(onRemoveCondition).not.toHaveBeenCalled()
})
})
describe('Loop variables', () => {
it('should render empty state and update loop variable items', async () => {
const user = userEvent.setup()
const handleRemoveLoopVariable = vi.fn()
const handleUpdateLoopVariable = vi.fn()
const { rerender } = render(
<LoopVariables
variables={[]}
nodeId="loop-node"
handleRemoveLoopVariable={handleRemoveLoopVariable}
handleUpdateLoopVariable={handleUpdateLoopVariable}
/>,
)
expect(screen.getByText('workflow.nodes.loop.setLoopVariables')).toBeInTheDocument()
rerender(
<LoopVariables
variables={[createLoopVariable({
value_type: ValueType.variable,
value: '',
})]}
nodeId="loop-node"
handleRemoveLoopVariable={handleRemoveLoopVariable}
handleUpdateLoopVariable={handleUpdateLoopVariable}
/>,
)
fireEvent.change(screen.getByDisplayValue('item'), { target: { value: 'loop_item' } })
await user.click(screen.getByText('pick-reference'))
await user.click(screen.getAllByRole('button').at(-1)!)
expect(handleUpdateLoopVariable).toHaveBeenCalledWith('loop-var-1', { label: 'loop_item' })
expect(handleUpdateLoopVariable).toHaveBeenCalledWith('loop-var-1', { value: '{{#node-1.score#}}' })
expect(handleRemoveLoopVariable).toHaveBeenCalledWith('loop-var-1')
})
it('should render variable mode, variable type, and form values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<div>
<InputModeSelect
value={ValueType.constant}
onChange={vi.fn()}
/>
<VariableTypeSelect
value={VarType.string}
onChange={vi.fn()}
/>
<FormItem
nodeId="loop-node"
item={createLoopVariable({
value_type: ValueType.constant,
var_type: VarType.arrayBoolean,
value: [false],
})}
onChange={onChange}
/>
</div>,
)
expect(screen.getByText('Constant')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
await user.click(screen.getByText('True'))
await user.click(screen.getByRole('button', { name: /workflow.chatVariable.modal.addArrayValue/i }))
expect(onChange).toHaveBeenCalledWith([true])
expect(onChange).toHaveBeenCalledWith([false, false])
})
it('should edit string and object loop variable values', () => {
const onStringChange = vi.fn()
const onObjectChange = vi.fn()
render(
<div>
<FormItem
nodeId="loop-node"
item={createLoopVariable({
id: 'loop-var-string',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'draft',
})}
onChange={onStringChange}
/>
<FormItem
nodeId="loop-node"
item={createLoopVariable({
id: 'loop-var-object',
var_type: VarType.arrayObject,
value_type: ValueType.constant,
value: '[{\"id\":1}]',
})}
onChange={onObjectChange}
/>
</div>,
)
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'published' } })
fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '[{\"id\":2}]' } })
expect(onStringChange).toHaveBeenCalledWith('published')
expect(onObjectChange).toHaveBeenCalledWith('[{"id":2}]')
})
})
describe('Node actions', () => {
it('should add and insert loop blocks', async () => {
const user = userEvent.setup()
render(
<div>
<AddBlock
loopNodeId="loop-node"
loopNodeData={createData({ start_node_id: 'start-node' })}
/>
<InsertBlock
startNodeId="start-node"
availableBlocksTypes={[BlockEnum.Code]}
/>
</div>,
)
await user.click(screen.getAllByText('select-block')[0]!)
await user.click(screen.getAllByText('select-block')[1]!)
expect(mockHandleNodeAdd).toHaveBeenCalledTimes(2)
expect(mockHandleNodeAdd).toHaveBeenCalledWith(expect.objectContaining({
nodeType: expect.any(String),
}), expect.objectContaining({
prevNodeId: 'start-node',
prevNodeSourceHandle: 'source',
}))
expect(mockHandleNodeAdd).toHaveBeenCalledWith(expect.objectContaining({
nodeType: expect.any(String),
}), expect.objectContaining({
nextNodeId: 'start-node',
nextNodeTargetHandle: 'target',
}))
})
it('should render loop node candidate state and rerender children', () => {
render(
<Node
id="loop-node"
data={createData({
_isCandidate: true,
_children: [{ nodeId: 'child-1', nodeType: BlockEnum.LoopStart }],
})}
/>,
)
expect(screen.getByText('loop-start-node')).toBeInTheDocument()
expect(screen.getByTestId('loop-background-loop-node')).toBeInTheDocument()
expect(screen.getByText('select-block')).toBeInTheDocument()
expect(mockHandleNodeLoopRerender).toHaveBeenCalledWith('loop-node')
})
})
describe('Panel integration', () => {
it('should add loop variables and update loop count from the panel', async () => {
const handleAddLoopVariable = vi.fn()
const handleUpdateLoopCount = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
break_conditions: [],
loop_variables: [],
}),
handleAddLoopVariable,
handleUpdateLoopCount,
}))
const { container } = render(
<Panel
id="loop-node"
data={createData({
break_conditions: [],
loop_variables: [],
})}
panelProps={panelProps}
/>,
)
fireEvent.click(container.querySelector('.mr-4.flex.h-5.w-5.cursor-pointer.items-center.justify-center') as HTMLElement)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '8' } })
expect(handleAddLoopVariable).toHaveBeenCalled()
expect(handleUpdateLoopCount).toHaveBeenCalledWith(8)
expect(screen.getByText('workflow.nodes.loop.setLoopVariables')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,851 @@
import type { ReactNode } from 'react'
import type { Var } from '../../../types'
import type { Param, ParameterExtractorNodeType } from '../types'
import type { ToolParameter } from '@/app/components/tools/types'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import {
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { CollectionType } from '@/app/components/tools/types'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../../../types'
import ImportFromTool from '../components/extract-parameter/import-from-tool'
import ExtractParameter from '../components/extract-parameter/list'
import AddExtractParameter from '../components/extract-parameter/update'
import ReasoningModePicker from '../components/reasoning-mode-picker'
import Node from '../node'
import Panel from '../panel'
import { ParamType, ReasoningModeType } from '../types'
import useConfig from '../use-config'
type MockToolCollection = {
id: string
tools: Array<{
name: string
parameters: ToolParameter[]
}>
}
let mockBuiltInTools: MockToolCollection[] = []
let mockCustomTools: MockToolCollection[] = []
let mockWorkflowTools: MockToolCollection[] = []
let mockSelectedToolInfo: ToolDefaultValue | undefined
let mockBlockSelectorOpen = false
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
trigger,
onSelect,
}: {
trigger?: (open: boolean) => ReactNode
onSelect?: (type: BlockEnum, value?: ToolDefaultValue) => void
}) => (
<button
type="button"
onClick={() => onSelect?.(BlockEnum.Tool, mockSelectedToolInfo)}
>
{trigger ? trigger(mockBlockSelectorOpen) : 'select-tool'}
</button>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),
useAllCustomTools: () => ({ data: mockCustomTools }),
useAllWorkflowTools: () => ({ data: mockWorkflowTools }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: ({
setModel,
onCompletionParamsChange,
}: {
setModel: (model: { provider: string, modelId: string, mode?: string }) => void
onCompletionParamsChange: (params: Record<string, unknown>) => void
}) => (
<div>
<button
type="button"
onClick={() => setModel({ provider: 'anthropic', modelId: 'claude-3-7-sonnet', mode: AppModeEnum.CHAT })}
>
set-model
</button>
<button
type="button"
onClick={() => onCompletionParamsChange({ temperature: 0.2 })}
>
set-params
</button>
</div>
),
}))
vi.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({
children,
isShow,
title,
}: {
children: ReactNode
isShow?: boolean
title?: ReactNode
}) => isShow
? (
<div data-testid="base-modal">
<div>{title}</div>
{children}
</div>
)
: null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
FieldCollapse: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, operations, children }: { title: ReactNode, operations?: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
<div>{operations}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => ({
__esModule: true,
default: ({
onEnabledChange,
onConfigChange,
}: {
onEnabledChange: (enabled: boolean) => void
onConfigChange: (value: { variable_selector: string[], detail: string }) => void
}) => (
<div>
<button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button>
<button type="button" onClick={() => onConfigChange({ variable_selector: ['node-1', 'image'], detail: 'high' })}>vision-config</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: { enabled: boolean }) => void
}) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
__esModule: true,
default: ({
title,
value,
onChange,
}: {
title: ReactNode
value: string
onChange: (value: string) => void
}) => (
<div>
<div>{typeof title === 'string' ? title : 'editor-title'}</div>
<textarea
aria-label="instruction-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: string[]) => void
}) => <button type="button" onClick={() => onChange(['node-1', 'query'])}>pick-var</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
__esModule: true,
default: ({
title,
onSelect,
}: {
title: string
onSelect: () => void
}) => <button type="button" onClick={onSelect}>{title}</button>,
}))
vi.mock('@/app/components/app/configuration/config-var/config-modal/field', () => ({
__esModule: true,
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({
__esModule: true,
default: ({
options,
onChange,
}: {
options: string[]
onChange: (value: string[]) => void
}) => (
<div>
<div>{options.join(',')}</div>
<button type="button" onClick={() => onChange([...options, 'published'])}>set-options</button>
</div>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseTextGeneration = vi.mocked(useTextGenerationCurrentProviderAndModelAndModelList)
const mockUseConfig = vi.mocked(useConfig)
const mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
const createToolParameter = (overrides: Partial<ToolParameter> = {}): ToolParameter => ({
name: 'city',
label: { en_US: 'City', zh_Hans: '城市' },
human_description: { en_US: 'City input', zh_Hans: '城市输入' },
type: ParamType.string,
form: 'llm',
llm_description: 'City name',
required: true,
multiple: false,
default: '',
options: [
{
value: 'draft',
label: { en_US: 'Draft', zh_Hans: '草稿' },
},
],
...overrides,
})
const createToolInfo = (overrides: Partial<ToolDefaultValue> = {}): ToolDefaultValue => ({
provider_id: 'builtin-1',
provider_type: CollectionType.builtIn,
provider_name: 'builtin',
tool_name: 'search',
tool_label: 'Search',
tool_description: 'Search tool',
title: 'Search',
is_team_authorization: false,
params: {},
paramSchemas: [],
output_schema: {},
...overrides,
})
const createParam = (overrides: Partial<Param> = {}): Param => ({
name: 'city',
type: ParamType.string,
description: 'City name',
required: false,
...overrides,
})
const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): ParameterExtractorNodeType => ({
title: 'Parameter Extractor',
desc: '',
type: BlockEnum.ParameterExtractor,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {},
},
query: ['node-1', 'query'],
reasoning_mode: ReasoningModeType.prompt,
parameters: [createParam()],
instruction: 'Extract city and budget',
vision: {
enabled: false,
},
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
handleInputVarChange: vi.fn(),
filterVar: (_varPayload: Var) => true,
isChatMode: true,
inputs: createData(),
isChatModel: true,
isCompletionModel: false,
handleModelChanged: vi.fn(),
handleCompletionParamsChange: vi.fn(),
handleImportFromTool: vi.fn(),
handleExactParamsChange: vi.fn(),
addExtractParameter: vi.fn(),
handleInstructionChange: vi.fn(),
hasSetBlockStatus: { history: false, query: false, context: false },
availableVars: [],
availableNodesWithParent: [],
isSupportFunctionCall: true,
handleReasoningModeChange: vi.fn(),
handleMemoryChange: vi.fn(),
isVisionModel: true,
handleVisionResolutionEnabledChange: vi.fn(),
handleVisionResolutionChange: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('parameter-extractor path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify.mockClear()
mockBuiltInTools = []
mockCustomTools = []
mockWorkflowTools = []
mockSelectedToolInfo = createToolInfo()
mockBlockSelectorOpen = false
mockUseTextGeneration.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
textGenerationModelList: [],
activeTextGenerationModelList: [],
} as unknown as ReturnType<typeof useTextGenerationCurrentProviderAndModelAndModelList>)
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Tool import and parameter editing', () => {
it('should import llm parameters from the selected tool', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockBuiltInTools = [
{
id: 'builtin-1',
tools: [
{
name: 'search',
parameters: [
createToolParameter(),
createToolParameter({
name: 'internal_only',
form: 'form',
}),
],
},
],
},
]
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenCalledWith([
{
name: 'city',
type: ParamType.string,
required: true,
description: 'City name',
options: ['Draft'],
},
])
})
it('should ignore invalid tool selections when importing parameters', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockSelectedToolInfo = undefined
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).not.toHaveBeenCalled()
})
it('should import llm parameters from custom and workflow tool collections', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockSelectedToolInfo = createToolInfo({
provider_id: 'custom-1',
provider_type: CollectionType.custom,
})
mockCustomTools = [
{
id: 'custom-1',
tools: [
{
name: 'search',
parameters: [createToolParameter({ name: 'custom_city', llm_description: 'Custom city' })],
},
],
},
]
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenLastCalledWith([
{
name: 'custom_city',
type: ParamType.string,
required: true,
description: 'Custom city',
options: ['Draft'],
},
])
})
it('should import llm parameters from workflow tool collections', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockSelectedToolInfo = createToolInfo({
provider_id: 'workflow-1',
provider_type: CollectionType.workflow,
tool_name: 'transform',
})
mockWorkflowTools = [
{
id: 'workflow-1',
tools: [
{
name: 'transform',
parameters: [createToolParameter({ name: 'workflow_city', llm_description: 'Workflow city' })],
},
],
},
]
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenLastCalledWith([
{
name: 'workflow_city',
type: ParamType.string,
required: true,
description: 'Workflow city',
options: ['Draft'],
},
])
})
it('should highlight the trigger when open and return an empty import for unknown providers', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockBlockSelectorOpen = true
mockSelectedToolInfo = createToolInfo({
provider_type: 'unknown' as CollectionType,
})
render(<ImportFromTool onImport={onImport} />)
expect(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')).toHaveClass('bg-state-base-hover')
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenCalledWith([])
})
it('should show the empty state for an empty parameter list', () => {
render(
<ExtractParameter
readonly={false}
list={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('workflow.nodes.parameterExtractor.extractParametersNotSet')).toBeInTheDocument()
})
it('should edit and delete parameters from the list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container, rerender } = render(
<ExtractParameter
readonly={false}
list={[createParam()]}
onChange={onChange}
/>,
)
const editAndDeleteButtons = container.querySelectorAll('.cursor-pointer.rounded-md.p-1')
fireEvent.click(editAndDeleteButtons[0] as HTMLElement)
fireEvent.change(screen.getByDisplayValue('city'), { target: { value: 'city_name' } })
fireEvent.change(screen.getByDisplayValue('City name'), { target: { value: 'Updated city description' } })
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onChange).toHaveBeenCalledWith([
{
name: 'city_name',
type: ParamType.string,
description: 'Updated city description',
required: false,
},
], undefined)
onChange.mockClear()
rerender(
<ExtractParameter
readonly={false}
list={[createParam({ name: 'budget' })]}
onChange={onChange}
/>,
)
const deleteButtons = container.querySelectorAll('.cursor-pointer.rounded-md.p-1')
fireEvent.click(deleteButtons[1] as HTMLElement)
expect(onChange).toHaveBeenCalledWith([])
})
it('should validate required fields before saving an incomplete parameter', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: '',
description: '',
})}
onSave={onSave}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalled()
})
it('should render the add trigger for new parameters', () => {
render(
<AddExtractParameter
type="add"
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('add-button')).toBeInTheDocument()
})
it('should reject invalid names and reset add modal fields after canceling', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<AddExtractParameter
type="add"
onSave={vi.fn()}
onCancel={onCancel}
/>,
)
await user.click(screen.getByTestId('add-button'))
const nameInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')
const descriptionInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder')
fireEvent.change(nameInput, { target: { value: '1bad' } })
expect(mockToastNotify).toHaveBeenCalled()
expect(nameInput).toHaveValue('')
fireEvent.change(nameInput, { target: { value: 'temporary_name' } })
fireEvent.change(descriptionInput, { target: { value: 'Temporary description' } })
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('base-modal')).not.toBeInTheDocument()
await user.click(screen.getByTestId('add-button'))
expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder')).toHaveValue('')
})
it('should require select options before saving a select parameter', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: 'status',
type: ParamType.select,
description: 'Status field',
options: [],
})}
onSave={onSave}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalled()
})
it('should keep rename metadata and updated options when editing a select parameter', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: 'status',
type: ParamType.select,
description: 'Status',
options: ['draft'],
})}
onSave={onSave}
/>,
)
fireEvent.change(screen.getByDisplayValue('status'), {
target: { value: 'approval_status' },
})
await user.click(screen.getByRole('button', { name: 'set-options' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
name: 'approval_status',
type: ParamType.select,
description: 'Status',
options: ['draft', 'published'],
required: false,
}, undefined)
})
it('should persist rename metadata and required state for edited parameters', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: 'status',
description: 'Status description',
})}
onSave={onSave}
/>,
)
fireEvent.change(screen.getByDisplayValue('status'), {
target: { value: 'approval_status' },
})
await user.click(screen.getByRole('switch'))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
name: 'approval_status',
type: ParamType.string,
description: 'Status description',
required: true,
}, undefined)
})
})
describe('Node and panel integration', () => {
it('should let users switch the reasoning mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ReasoningModePicker
type={ReasoningModeType.prompt}
onChange={onChange}
/>,
)
await user.click(screen.getByRole('button', { name: 'Function/Tool Calling' }))
await user.click(screen.getByRole('button', { name: 'Prompt' }))
expect(onChange).toHaveBeenNthCalledWith(1, ReasoningModeType.functionCall)
expect(onChange).toHaveBeenNthCalledWith(2, ReasoningModeType.prompt)
})
it('should render the selected model on the node only when configured', () => {
const { rerender } = render(
<Node
id="parameter-node"
data={createData()}
/>,
)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()
rerender(
<Node
id="parameter-node"
data={createData({
model: {
provider: '',
name: '',
mode: AppModeEnum.CHAT,
completion_params: {},
},
})}
/>,
)
expect(screen.queryByText('openai:gpt-4o')).not.toBeInTheDocument()
})
it('should wire panel actions across model, input, import, vision, memory, and outputs', async () => {
const user = userEvent.setup()
const handleModelChanged = vi.fn()
const handleCompletionParamsChange = vi.fn()
const handleInputVarChange = vi.fn()
const handleImportFromTool = vi.fn()
const handleInstructionChange = vi.fn()
const handleMemoryChange = vi.fn()
const handleReasoningModeChange = vi.fn()
const handleVisionResolutionEnabledChange = vi.fn()
const handleVisionResolutionChange = vi.fn()
mockBuiltInTools = [
{
id: 'builtin-1',
tools: [
{
name: 'search',
parameters: [createToolParameter()],
},
],
},
]
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
parameters: [createParam({ name: 'city' }), createParam({ name: 'budget', type: ParamType.number })],
}),
handleModelChanged,
handleCompletionParamsChange,
handleInputVarChange,
handleImportFromTool,
handleInstructionChange,
handleMemoryChange,
handleReasoningModeChange,
handleVisionResolutionEnabledChange,
handleVisionResolutionChange,
}))
render(
<Panel
id="parameter-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'set-model' }))
await user.click(screen.getByRole('button', { name: 'set-params' }))
await user.click(screen.getByRole('button', { name: 'pick-var' }))
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
await user.click(screen.getByRole('button', { name: 'vision-toggle' }))
await user.click(screen.getByRole('button', { name: 'vision-config' }))
fireEvent.change(screen.getByLabelText('instruction-editor'), {
target: { value: 'Extract city, budget, and due date' },
})
await user.click(screen.getByRole('button', { name: 'memory-config' }))
await user.click(screen.getByRole('button', { name: 'Function/Tool Calling' }))
expect(handleModelChanged).toHaveBeenCalledWith({
provider: 'anthropic',
modelId: 'claude-3-7-sonnet',
mode: AppModeEnum.CHAT,
})
expect(handleCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.2 })
expect(handleInputVarChange).toHaveBeenCalledWith(['node-1', 'query'])
expect(handleImportFromTool).toHaveBeenCalledWith([
{
name: 'city',
type: ParamType.string,
required: true,
description: 'City name',
options: ['Draft'],
},
])
expect(handleVisionResolutionEnabledChange).toHaveBeenCalledWith(true)
expect(handleVisionResolutionChange).toHaveBeenCalledWith({
variable_selector: ['node-1', 'image'],
detail: 'high',
})
expect(handleInstructionChange).toHaveBeenCalledWith('Extract city, budget, and due date')
expect(handleMemoryChange).toHaveBeenCalledWith({ enabled: true })
expect(handleReasoningModeChange).toHaveBeenCalledWith(ReasoningModeType.functionCall)
expect(screen.getByText('city:string')).toBeInTheDocument()
expect(screen.getByText('budget:number')).toBeInTheDocument()
expect(screen.getByText('__usage:object')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,385 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { QuestionClassifierNodeType, Topic } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { useEdgesInteractions } from '../../../hooks'
import AdvancedSetting from '../components/advanced-setting'
import ClassItem from '../components/class-item'
import ClassList from '../components/class-list'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: ({ title, value, onChange, onRemove, showRemove, headerClassName }: any) => (
<div className={headerClassName}>
<div>{typeof title === 'string' ? title : 'editor-title'}</div>
<input value={value} onChange={event => onChange(event.target.value)} />
{showRemove && <button type="button" onClick={onRemove}>remove-item</button>}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>,
}))
vi.mock('../../_base/hooks/use-available-var-list', () => ({
default: vi.fn(() => ({
availableVars: [{ variable: ['node-1', 'answer'], type: VarType.string }],
availableNodesWithParent: [{ id: 'node-1', data: { title: 'Answer', type: BlockEnum.Answer } }],
})),
}))
vi.mock('../../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../hooks')>()
return {
...actual,
useEdgesInteractions: vi.fn(),
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/add-button', () => ({
default: ({ text, onClick }: any) => <button type="button" onClick={onClick}>{text}</button>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: any) => <div>{defaultModel.provider}:{defaultModel.model}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
default: ({ value }: any) => <div>{value}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeSourceHandle: ({ handleId }: any) => <div>handle-{handleId}</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: ({ setModel, onCompletionParamsChange }: any) => (
<div>
<button type="button" onClick={() => setModel({ provider: 'openai', name: 'gpt-4o' })}>set-model</button>
<button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>set-params</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
FieldCollapse: ({ title, children }: any) => <div><div>{title}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => ({
default: ({ onEnabledChange, onConfigChange }: any) => (
<div>
<button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button>
<button type="button" onClick={() => onConfigChange({ resolution: 'high' })}>vision-config</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange(['node-1', 'query'])}>var-picker</button>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseEdgesInteractions = vi.mocked(useEdgesInteractions)
const mockUseTextGeneration = vi.mocked(useTextGenerationCurrentProviderAndModelAndModelList)
const mockUseConfig = vi.mocked(useConfig)
const createTopic = (overrides: Partial<Topic> = {}): Topic => ({
id: 'topic-1',
name: 'Billing questions',
...overrides,
})
const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({
title: 'Question Classifier',
desc: '',
type: BlockEnum.QuestionClassifier,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: {},
},
classes: [createTopic()],
query_variable_selector: ['node-1', 'query'],
instruction: 'Route by topic',
memory: undefined,
vision: {
enabled: false,
},
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleModelChanged: vi.fn(),
isChatMode: true,
isChatModel: true,
handleCompletionParamsChange: vi.fn(),
handleQueryVarChange: vi.fn(),
filterVar: vi.fn(() => true),
handleTopicsChange: vi.fn(),
hasSetBlockStatus: { context: false, history: false, query: false },
availableVars: [],
availableNodesWithParent: [],
availableVisionVars: [],
handleInstructionChange: vi.fn(),
handleMemoryChange: vi.fn(),
isVisionModel: true,
handleVisionResolutionEnabledChange: vi.fn(),
handleVisionResolutionChange: vi.fn(),
handleSortTopic: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: QuestionClassifierNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('question-classifier path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseEdgesInteractions.mockReturnValue({
handleEdgeDeleteByDeleteBranch: vi.fn(),
} as unknown as ReturnType<typeof useEdgesInteractions>)
mockUseTextGeneration.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
textGenerationModelList: [{ provider: 'openai', model: 'gpt-4o', status: 'active' } as any],
activeTextGenerationModelList: [{ provider: 'openai', model: 'gpt-4o', status: 'active' } as any],
})
mockUseConfig.mockReturnValue(createConfigResult())
})
// The question classifier path should wire editor-based classes, model display, and panel controls together.
describe('Path Integration', () => {
it('should render advanced settings and memory config', async () => {
const user = userEvent.setup()
const onInstructionChange = vi.fn()
const onMemoryChange = vi.fn()
render(
<AdvancedSetting
instruction="Route by topic"
onInstructionChange={onInstructionChange}
hideMemorySetting={false}
onMemoryChange={onMemoryChange}
isChatModel
isChatApp
nodesOutputVars={[]}
availableNodes={[]}
/>,
)
await user.type(screen.getByDisplayValue('Route by topic'), '!')
await user.click(screen.getByText('memory-config'))
expect(onInstructionChange).toHaveBeenCalled()
expect(onMemoryChange).toHaveBeenCalledWith({ enabled: true })
})
it('should edit and remove a single class item', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<ClassItem
nodeId="node-1"
payload={createTopic()}
onChange={onChange}
onRemove={onRemove}
index={1}
filterVar={() => true}
/>,
)
await user.type(screen.getByDisplayValue('Billing questions'), ' updated')
await user.click(screen.getByText('remove-item'))
expect(onChange).toHaveBeenCalled()
expect(onRemove).toHaveBeenCalled()
})
it('should add classes and collapse the class list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ClassList
nodeId="node-1"
list={[createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]}
onChange={onChange}
filterVar={() => true}
/>,
)
await user.click(screen.getByText('workflow.nodes.questionClassifiers.addClass'))
await user.click(screen.getByText('workflow.nodes.questionClassifiers.class'))
expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.questionClassifiers.class'))
expect(screen.getByText('workflow.nodes.questionClassifiers.addClass')).toBeInTheDocument()
expect(container.querySelector('.handle')).not.toBeNull()
expect(onChange).toHaveBeenCalled()
})
it('should update and remove classes from the class list and delete the related edge branch', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const handleEdgeDeleteByDeleteBranch = vi.fn()
mockUseEdgesInteractions.mockReturnValueOnce({
handleEdgeDeleteByDeleteBranch,
} as unknown as ReturnType<typeof useEdgesInteractions>)
render(
<ClassList
nodeId="node-1"
list={[createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]}
onChange={onChange}
filterVar={() => true}
/>,
)
fireEvent.change(screen.getByDisplayValue('Billing questions'), { target: { value: 'Updated billing' } })
await user.click(screen.getAllByText('remove-item')[0]!)
expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ name: 'Updated billing' }),
]))
expect(handleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('node-1', 'topic-1')
})
it('should disable dragging and hide the add button when the class list is readonly', () => {
const { container } = render(
<ClassList
nodeId="node-1"
list={[createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]}
onChange={vi.fn()}
filterVar={() => true}
readonly
/>,
)
expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument()
expect(container.querySelector('.handle')).toBeNull()
})
it('should render the node model and output handles for each class', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({ classes: [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })] })}
type="custom"
selected={false}
zIndex={1}
xPos={0}
yPos={0}
dragging={false}
isConnectable
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()
expect(screen.getByText('Billing questions')).toBeInTheDocument()
expect(screen.getByText('handle-topic-1')).toBeInTheDocument()
expect(screen.getByText('handle-topic-2')).toBeInTheDocument()
})
it('should render the node when only classes are set and return null when both model and classes are missing', async () => {
const user = userEvent.setup()
const longName = 'L'.repeat(60)
const { rerender } = renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({
model: { provider: '', name: '', mode: 'chat', completion_params: {} },
classes: [createTopic({ id: 'topic-2', name: longName })],
})}
type="custom"
selected={false}
zIndex={1}
xPos={0}
yPos={0}
dragging={false}
isConnectable
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument()
await user.hover(screen.getByText(`${longName.slice(0, 50)}...`))
expect(screen.getByText(longName)).toBeInTheDocument()
rerender(
<Node
id="node-1"
data={createData({
model: { provider: '', name: '', mode: 'chat', completion_params: {} },
classes: [],
})}
type="custom"
selected={false}
zIndex={1}
xPos={0}
yPos={0}
dragging={false}
isConnectable
/>,
)
expect(screen.queryByText('openai:gpt-4o')).not.toBeInTheDocument()
expect(screen.queryByText(`${longName.slice(0, 50)}...`)).not.toBeInTheDocument()
})
it('should render the panel controls and output variables', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByText('set-model'))
await user.click(screen.getByText('set-params'))
await user.click(screen.getAllByText('var-picker')[0]!)
await user.click(screen.getByText('vision-toggle'))
await user.click(screen.getByText('vision-config'))
expect(screen.getByText('class_name:string')).toBeInTheDocument()
expect(screen.getByText('usage:object')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,224 @@
import type { ReactNode } from 'react'
import type { Variable } from '../../../types'
import type { TemplateTransformNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, VarType } from '../../../types'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, operations, children }: { title: ReactNode, operations?: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
<div>{operations}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
__esModule: true,
default: ({
onChange,
onVarNameChange,
}: {
onChange: (value: Variable[]) => void
onVarNameChange: (oldName: string, newName: string) => void
}) => (
<div>
<button
type="button"
onClick={() => onChange([{
variable: 'updated_input',
value_selector: ['node-1', 'updated_input'],
value_type: VarType.string,
}])}
>
change-var-list
</button>
<button type="button" onClick={() => onVarNameChange('input_text', 'renamed_input')}>
rename-var
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars', () => ({
__esModule: true,
default: ({
onAddVar,
headerRight,
value,
onChange,
}: {
onAddVar: (value: Variable) => void
headerRight?: ReactNode
value: string
onChange: (value: string) => void
}) => (
<div>
<div>{headerRight}</div>
<button
type="button"
onClick={() => onAddVar({
variable: 'result_text',
value_selector: ['node-2', 'result_text'],
value_type: VarType.string,
})}
>
add-var
</button>
<textarea
aria-label="template-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
</div>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createVariable = (overrides: Partial<Variable> = {}): Variable => ({
variable: 'input_text',
value_selector: ['node-1', 'input_text'],
value_type: VarType.string,
...overrides,
})
const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({
title: 'Template Transform',
desc: '',
type: BlockEnum.TemplateTransform,
variables: [createVariable()],
template: '{{ input_text }}',
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
availableVars: [],
handleVarListChange: vi.fn(),
handleVarNameChange: vi.fn(),
handleAddVariable: vi.fn(),
handleAddEmptyVariable: vi.fn(),
handleCodeChange: vi.fn(),
filterVar: () => true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('template-transform path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should render the node shell without summary content', () => {
const { container } = render(
<Node
id="template-node"
data={createData()}
/>,
)
expect(container.firstElementChild).toBeEmptyDOMElement()
})
it('should wire variable list and code editor actions from the panel', async () => {
const user = userEvent.setup()
const handleVarListChange = vi.fn()
const handleVarNameChange = vi.fn()
const handleAddVariable = vi.fn()
const handleAddEmptyVariable = vi.fn()
const handleCodeChange = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
handleVarListChange,
handleVarNameChange,
handleAddVariable,
handleAddEmptyVariable,
handleCodeChange,
}))
render(
<Panel
id="template-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByTestId('add-button'))
await user.click(screen.getByRole('button', { name: 'change-var-list' }))
await user.click(screen.getByRole('button', { name: 'rename-var' }))
await user.click(screen.getByRole('button', { name: 'add-var' }))
fireEvent.change(screen.getByLabelText('template-editor'), { target: { value: '{{ renamed_input }}' } })
expect(handleAddEmptyVariable).toHaveBeenCalled()
expect(handleVarListChange).toHaveBeenCalledWith([
{
variable: 'updated_input',
value_selector: ['node-1', 'updated_input'],
value_type: VarType.string,
},
])
expect(handleVarNameChange).toHaveBeenCalledWith('input_text', 'renamed_input')
expect(handleAddVariable).toHaveBeenCalledWith({
variable: 'result_text',
value_selector: ['node-2', 'result_text'],
value_type: VarType.string,
})
expect(handleCodeChange).toHaveBeenCalledWith('{{ renamed_input }}')
expect(screen.getByText('output:string')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /workflow.nodes.templateTransform.codeSupportTip/i })).toHaveAttribute(
'href',
'https://jinja.palletsprojects.com/en/3.1.x/templates/',
)
})
it('should hide the add-variable operation when the panel is read only', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
readOnly: true,
}))
render(
<Panel
id="template-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.queryByTestId('add-button')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,513 @@
import type { ToolVarInputs } from '../../types'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { App } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
FormTypeEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { ProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import { VarType as ToolVarType } from '../../types'
import InputVarList from '../input-var-list'
const mockUseAvailableVarList = vi.fn()
const mockFetchNextPage = vi.fn()
const mockApps: App[] = [
{
id: 'app-1',
name: 'Weather Assistant',
mode: AppModeEnum.CHAT,
icon_type: 'emoji',
icon: 'W',
icon_background: '#FFEAD5',
model_config: {
user_input_form: [{
'text-input': {
label: 'Topic',
variable: 'topic',
},
}],
},
} as App,
]
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
root = null
rootMargin = ''
thresholds: number[] = []
takeRecords = vi.fn().mockReturnValue([])
constructor(_callback: IntersectionObserverCallback) {}
}
class MockMutationObserver {
observe = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
constructor(_callback: MutationCallback) {}
}
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
useModelList: () => ({
data: [{
provider: 'openai',
icon_small: {
en_US: 'https://example.com/openai.png',
zh_Hans: 'https://example.com/openai.png',
},
label: {
en_US: 'OpenAI',
zh_Hans: 'OpenAI',
},
models: [{
model: 'gpt-4o',
label: {
en_US: 'GPT-4o',
zh_Hans: 'GPT-4o',
},
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {
mode: 'chat',
},
load_balancing_enabled: false,
features: [],
}],
status: ModelStatusEnum.active,
}],
mutate: vi.fn(),
isLoading: false,
}),
useMarketplaceAllPlugins: () => ({
plugins: [],
isLoading: false,
}),
useUpdateModelList: () => vi.fn(),
useUpdateModelProviders: () => vi.fn(),
useCurrentProviderAndModel: (
modelList: Array<{
provider: string
models: Array<{ model: string }>
}>,
defaultModel?: { provider: string, model: string },
) => {
const currentProvider = modelList.find(provider => provider.provider === defaultModel?.provider)
const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model)
return {
currentProvider,
currentModel,
}
},
}))
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: {
pages: [{
data: mockApps,
}],
},
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
}),
useAppDetail: (appId: string) => ({
data: mockApps.find(app => app.id === appId),
isFetching: false,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: undefined,
isFetching: false,
}),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: {
image_file_size_limit: 10,
file_size_limit: 15,
audio_file_size_limit: 50,
video_file_size_limit: 100,
workflow_file_upload_limit: 10,
},
}),
useModelParameterRules: () => ({
data: {
data: [],
},
isPending: false,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({
value,
onChange,
onFocusChange,
placeholder,
}: {
value: string
onChange: (value: string) => void
onFocusChange?: (value: boolean) => void
placeholder?: string
}) => (
<input
aria-label={placeholder || 'mixed-input'}
value={value}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({
onChange,
onOpen,
schema,
defaultVarKindType,
}: {
onChange: (value: string[] | string, kind: ToolVarType) => void
onOpen?: () => void
schema?: { variable?: string }
defaultVarKindType?: ToolVarType
}) => (
<button
type="button"
onClick={() => {
onOpen?.()
if (defaultVarKindType === ToolVarType.variable)
onChange(['node-1', 'file'], ToolVarType.variable)
else
onChange('42', defaultVarKindType || ToolVarType.constant)
}}
>
{`pick-${schema?.variable || 'var'}`}
</button>
),
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
trial_models: [],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => ({
isExhausted: false,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority', () => ({
useChangeProviderPriority: () => ({
isChangingPriority: false,
handleChangePriority: vi.fn(),
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: () => ({
variant: 'api-active',
priority: 'apiKeyOnly',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary key',
credits: 0,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({
useCredentialStatus: () => ({
hasCredential: true,
authorized: true,
current_credential_name: 'Primary key',
}),
}))
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({
check: vi.fn(),
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: vi.fn(),
}),
}))
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/utils/completion-params', () => ({
fetchAndMergeValidCompletionParams: vi.fn(async () => ({
params: {},
removedDetails: {},
})),
}))
const createSchemaItem = (
variable: string,
type: FormTypeEnum,
overrides: Partial<CredentialFormSchema> = {},
): CredentialFormSchema => ({
variable,
name: variable,
label: {
en_US: `${variable}-label`,
zh_Hans: `${variable}-label`,
},
type,
required: false,
show_on: [],
...overrides,
})
type TestHarnessProps = {
schema: CredentialFormSchema[]
initialValue?: ToolVarInputs
onChangeSpy?: (value: ToolVarInputs) => void
onOpen?: (index: number) => void
}
const TestHarness = ({
schema,
initialValue = {},
onChangeSpy,
onOpen,
}: TestHarnessProps) => {
const [value, setValue] = useState<ToolVarInputs>(initialValue)
return (
<InputVarList
readOnly={false}
nodeId="tool-node"
schema={schema}
value={value}
onChange={(nextValue) => {
setValue(nextValue)
onChangeSpy?.(nextValue)
}}
onOpen={onOpen}
/>
)
}
const renderInputVarList = (ui: React.ReactElement) => {
const providerContextValue = createMockProviderContextValue({
isAPIKeySet: true,
modelProviders: [{
provider: 'openai',
label: {
en_US: 'OpenAI',
zh_Hans: 'OpenAI',
},
preferred_provider_type: 'custom',
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: [ModelTypeEnum.textGeneration],
}] as ReturnType<typeof createMockProviderContextValue>['modelProviders'],
})
return render(
<ProviderContext.Provider value={providerContextValue}>
{ui}
</ProviderContext.Provider>,
)
}
describe('InputVarList', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
vi.stubGlobal('MutationObserver', MockMutationObserver)
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableVarList.mockReturnValue({
availableVars: [{
nodeId: 'node-1',
title: 'Node 1',
vars: [{ variable: 'score', type: VarType.number }],
}],
availableNodesWithParent: [],
})
})
afterAll(() => {
vi.unstubAllGlobals()
})
it('should render schema labels and update mixed text inputs', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('query', FormTypeEnum.textInput, {
required: true,
tooltip: {
en_US: 'query-tip',
zh_Hans: 'query-tip',
},
}),
]}
onChangeSpy={onChange}
/>,
)
expect(screen.getByText('query-label')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('Required')).toBeInTheDocument()
expect(screen.getByText('query-tip')).toBeInTheDocument()
await user.type(screen.getByLabelText('workflow.nodes.http.insertVarPlaceholder'), 'hello')
expect(onChange).toHaveBeenLastCalledWith({
query: {
type: ToolVarType.mixed,
value: 'hello',
},
})
})
it('should transform variable picker selections for number and file fields and report picker openings', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('limit', FormTypeEnum.textNumber),
createSchemaItem('attachment', FormTypeEnum.file),
]}
onOpen={onOpen}
onChangeSpy={onChange}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-limit' }))
await user.click(screen.getByRole('button', { name: 'pick-var' }))
expect(onOpen).toHaveBeenNthCalledWith(1, 0)
expect(onOpen).toHaveBeenNthCalledWith(2, 1)
expect(onChange).toHaveBeenNthCalledWith(1, {
limit: {
type: ToolVarType.constant,
value: '42',
},
})
expect(onChange).toHaveBeenNthCalledWith(2, {
limit: {
type: ToolVarType.constant,
value: '42',
},
attachment: {
type: ToolVarType.variable,
value: ['node-1', 'file'],
},
})
})
it('should replace app selections and merge model selections into existing values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('assistant', FormTypeEnum.appSelector),
createSchemaItem('model', FormTypeEnum.modelSelector, {
scope: 'llm',
}),
]}
initialValue={{
model: {
credential_id: 'credential-1',
},
} as unknown as ToolVarInputs}
onChangeSpy={onChange}
/>,
)
await user.click(screen.getAllByText('app.appSelector.placeholder')[0]!)
await user.click(screen.getAllByText('app.appSelector.placeholder')[1]!)
await user.click(screen.getByTitle('Weather Assistant (app-1)'))
await user.type(screen.getByPlaceholderText('Topic'), 'weather')
expect(onChange).toHaveBeenNthCalledWith(1, {
assistant: {
app_id: 'app-1',
inputs: {},
files: [],
},
model: {
credential_id: 'credential-1',
},
})
expect(onChange).toHaveBeenLastCalledWith({
assistant: {
app_id: 'app-1',
inputs: { topic: 'weather' },
files: [],
},
model: {
credential_id: 'credential-1',
},
})
await user.click(screen.getByText('workflow:errorMsg.configureModel'))
await user.click(await screen.findByRole('button', { name: 'plugin.detailPanel.configureModel' }))
await user.click(await screen.findByRole('button', { name: /GPT-4o/i }))
expect(onChange).toHaveBeenLastCalledWith({
assistant: {
app_id: 'app-1',
inputs: { topic: 'weather' },
files: [],
},
model: {
completion_params: {},
credential_id: 'credential-1',
mode: 'chat',
provider: 'openai',
model: 'gpt-4o',
model_type: 'llm',
},
})
})
})

View File

@ -0,0 +1,266 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { ScheduleTriggerNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Panel from '../panel'
import useConfig from '../use-config'
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => (
<div>
<div>{title}</div>
<div>{operations}</div>
<div>{children}</div>
</div>
),
}))
vi.mock('../components/frequency-selector', () => ({
default: ({ frequency, onChange }: any) => (
<button type="button" onClick={() => onChange('weekly')}>
{frequency}
</button>
),
}))
vi.mock('../components/mode-toggle', () => ({
default: ({ mode, onChange }: any) => (
<button type="button" onClick={() => onChange(mode === 'visual' ? 'cron' : 'visual')}>
{mode}
</button>
),
}))
vi.mock('../components/monthly-days-selector', () => ({
default: ({ onChange }: any) => (
<button type="button" onClick={() => onChange([1, 'last'])}>
monthly-days
</button>
),
}))
vi.mock('../components/next-execution-times', () => ({
default: ({ data }: any) => <div>next-times-{data.mode}</div>,
}))
vi.mock('../components/on-minute-selector', () => ({
default: ({ onChange }: any) => (
<button type="button" onClick={() => onChange(25)}>
minute-selector
</button>
),
}))
vi.mock('../components/weekday-selector', () => ({
default: ({ onChange }: any) => (
<button type="button" onClick={() => onChange(['mon', 'wed'])}>
weekday-selector
</button>
),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
title: 'Schedule Trigger',
desc: '',
type: 'trigger-schedule' as ScheduleTriggerNodeType['type'],
mode: 'visual',
frequency: 'daily',
timezone: 'UTC',
visual_config: {
time: '11:30 AM',
weekdays: ['mon'],
on_minute: 15,
monthly_days: [1],
},
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (id: string, data: ScheduleTriggerNodeType) => (
render(<Panel id={id} data={data} panelProps={panelProps} />)
)
describe('TriggerSchedulePanel', () => {
const setInputs = vi.fn()
const handleModeChange = vi.fn()
const handleFrequencyChange = vi.fn()
const handleCronExpressionChange = vi.fn()
const handleWeekdaysChange = vi.fn()
const handleTimeChange = vi.fn()
const handleOnMinuteChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseConfig.mockReturnValue({
readOnly: false,
inputs: createData(),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
})
// The panel should wire the visual and cron controls back to the schedule config handlers.
describe('Panel Wiring', () => {
it('should render the visual controls and forward their callbacks', async () => {
const user = userEvent.setup()
renderPanel('node-1', createData())
await user.click(screen.getByRole('button', { name: 'visual' }))
await user.click(screen.getByRole('button', { name: 'daily' }))
await user.click(screen.getByDisplayValue('11:30 AM'))
await user.click(screen.getAllByText('02')[0]!)
await user.click(screen.getByText('45'))
await user.click(screen.getByText('PM'))
await user.click(screen.getByRole('button', { name: /operation\.ok/i }))
expect(handleModeChange).toHaveBeenCalledWith('cron')
expect(handleFrequencyChange).toHaveBeenCalledWith('weekly')
expect(handleTimeChange).toHaveBeenCalledWith('2:45 PM')
expect(screen.getByText('next-times-visual')).toBeInTheDocument()
})
it('should render weekday and monthly helpers for the matching frequencies', async () => {
const user = userEvent.setup()
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'weekly' }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-1', createData({ frequency: 'weekly' }))
await user.click(screen.getByRole('button', { name: 'weekday-selector' }))
expect(handleWeekdaysChange).toHaveBeenCalledWith(['mon', 'wed'])
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'weekly', visual_config: undefined as any }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-5', createData({ frequency: 'weekly', visual_config: undefined as any }))
await user.click(screen.getAllByRole('button', { name: 'weekday-selector' })[1]!)
expect(handleWeekdaysChange).toHaveBeenCalledTimes(2)
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'monthly', visual_config: undefined as any }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-2', createData({ frequency: 'monthly', visual_config: undefined as any }))
await user.click(screen.getByRole('button', { name: 'monthly-days' }))
expect(setInputs).toHaveBeenCalled()
})
it('should render cron mode and forward expression changes', () => {
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ mode: 'cron', frequency: undefined, cron_expression: '0 0 * * *' }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-3', createData({ mode: 'cron' }))
fireEvent.change(screen.getByDisplayValue('0 0 * * *'), { target: { value: '*/5 * * * *' } })
expect(handleCronExpressionChange).toHaveBeenCalledWith('*/5 * * * *')
})
it('should use daily and empty cron defaults when the schedule values are missing', () => {
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: undefined }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
const { rerender } = renderPanel('node-6', createData({ frequency: undefined }) as any)
expect(screen.getByRole('button', { name: 'daily' })).toBeInTheDocument()
expect(screen.getByDisplayValue('11:30 AM')).toBeInTheDocument()
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ mode: 'cron', frequency: undefined, cron_expression: undefined as any }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
rerender(<Panel id="node-7" data={createData({ mode: 'cron', frequency: undefined, cron_expression: undefined as any }) as any} panelProps={panelProps} />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('should render the hourly minute selector when the frequency is hourly', async () => {
const user = userEvent.setup()
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'hourly' }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-4', createData({ frequency: 'hourly' }))
await user.click(screen.getByRole('button', { name: 'minute-selector' }))
expect(handleOnMinuteChange).toHaveBeenCalledWith(25)
})
})
})

View File

@ -0,0 +1,151 @@
/* eslint-disable ts/no-explicit-any */
import type { ScheduleTriggerNodeType } from '../../types'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FrequencySelector from '../frequency-selector'
import ModeSwitcher from '../mode-switcher'
import ModeToggle from '../mode-toggle'
import MonthlyDaysSelector from '../monthly-days-selector'
import NextExecutionTimes from '../next-execution-times'
import OnMinuteSelector from '../on-minute-selector'
import WeekdaySelector from '../weekday-selector'
const createData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
title: 'Schedule Trigger',
desc: '',
type: 'trigger-schedule' as ScheduleTriggerNodeType['type'],
mode: 'visual',
frequency: 'daily',
timezone: 'UTC',
visual_config: {
time: '11:30 AM',
weekdays: ['mon'],
on_minute: 15,
monthly_days: [1],
},
...overrides,
})
describe('trigger-schedule components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The leaf controls should expose schedule actions and derived previews for the visual scheduler.
describe('Leaf Rendering', () => {
it('should select a new frequency from the dropdown options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FrequencySelector
frequency="daily"
onChange={onChange}
/>,
)
const trigger = screen.getByRole('button', { name: 'workflow.nodes.triggerSchedule.frequency.daily' })
fireEvent.click(trigger)
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
const listbox = await screen.findByRole('listbox')
await user.click(within(listbox).getByText('workflow.nodes.triggerSchedule.frequency.weekly'))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('weekly')
})
})
it('should switch between visual and cron modes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeSwitcher mode="visual" onChange={onChange} />)
await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron'))
expect(onChange).toHaveBeenCalledWith('cron')
})
it('should toggle the mode from visual to cron', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeToggle mode="visual" onChange={onChange} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('cron')
})
it('should toggle the mode from cron back to visual', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeToggle mode="cron" onChange={onChange} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('visual')
})
it('should change the hourly minute through the slider', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<OnMinuteSelector value={15} onChange={onChange} />)
const slider = screen.getByRole('slider')
slider.focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith(16, 0)
})
it('should keep at least one weekday selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<WeekdaySelector selectedDays={['mon']} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Mon' }))
expect(onChange).toHaveBeenCalledWith(['mon'])
})
it('should add a new weekday when the day is not selected yet', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<WeekdaySelector selectedDays={[]} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Tue' }))
expect(onChange).toHaveBeenCalledWith(['tue'])
})
it('should toggle monthly days and show the day-31 warning', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<MonthlyDaysSelector selectedDays={[31]} onChange={onChange} />)
expect(screen.getByText('workflow.nodes.triggerSchedule.lastDayTooltip')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.triggerSchedule.lastDay'))
expect(onChange).toHaveBeenCalled()
})
it('should render the upcoming execution times when the schedule is valid', () => {
render(<NextExecutionTimes data={createData()} />)
expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTimes')).toBeInTheDocument()
expect(screen.getAllByText(/^\d{2}$/).length).toBeGreaterThan(0)
})
it('should hide upcoming execution times when frequency is missing or cron is invalid', () => {
const { rerender, container } = render(<NextExecutionTimes data={createData({ frequency: undefined }) as any} />)
expect(container).toBeEmptyDOMElement()
rerender(<NextExecutionTimes data={createData({ mode: 'cron', cron_expression: 'bad cron' }) as any} />)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -0,0 +1,537 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { VariableAssignerNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import AddVariable from '../components/add-variable'
import NodeGroupItem from '../components/node-group-item'
import NodeVariableItem from '../components/node-variable-item'
import VarGroupItem from '../components/var-group-item'
import VarList from '../components/var-list'
import Panel from '../panel'
import useConfig from '../use-config'
const mockHandleAssignVariableValueChange = vi.fn()
const mockHandleGroupItemMouseEnter = vi.fn()
const mockHandleGroupItemMouseLeave = vi.fn()
const mockGetAvailableVars = vi.fn()
vi.mock('@/app/components/workflow/nodes/_base/components/add-variable-popup', () => ({
default: ({ onSelect }: any) => (
<button
type="button"
onClick={() => onSelect(['source-node', 'pickedVar'], { variable: 'pickedVar', type: VarType.string })}
>
confirm-add-variable
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ value, onChange, isAddBtnTrigger, onOpen, placeholder }: any) => (
<div>
<div>{Array.isArray(value) ? value.join('.') : ''}</div>
<button
type="button"
onClick={() => {
onOpen?.()
if (isAddBtnTrigger)
onChange(['source-node', 'groupVar'], 'variable', { variable: 'groupVar', type: VarType.string })
else
onChange(['source-node', 'updatedVar'])
}}
>
{isAddBtnTrigger ? 'add-variable-from-picker' : (placeholder || 'pick-var')}
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/remove-button', () => ({
default: ({ onClick }: any) => <button type="button" onClick={onClick}>remove-variable</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children, className }: any) => <div className={className}><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type, description }: any) => <div>{`${name}:${type}:${description}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({
default: ({ isShow, onCancel, onConfirm }: any) => isShow
? (
<div>
<button type="button" onClick={onCancel}>cancel-remove</button>
<button type="button" onClick={onConfirm}>confirm-remove</button>
</div>
)
: null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables, nodeTitle, isExceptionVariable }: any) => (
<div>{`${nodeTitle}:${variables.join('.')}:${String(Boolean(isExceptionVariable))}`}</div>
),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
VarBlockIcon: ({ type }: any) => <div>{`block-icon:${type}`}</div>,
}))
vi.mock('../hooks', () => ({
useVariableAssigner: () => ({
handleAssignVariableValueChange: mockHandleAssignVariableValueChange,
handleGroupItemMouseEnter: mockHandleGroupItemMouseEnter,
handleGroupItemMouseLeave: mockHandleGroupItemMouseLeave,
}),
useGetAvailableVars: () => mockGetAvailableVars,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({
title: 'Variable Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
advanced_settings: {
group_enabled: true,
groups: [
{
groupId: 'group-1',
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
},
{
groupId: 'group-2',
group_name: 'Group2',
output_type: VarType.number,
variables: [],
},
],
},
selected: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleListOrTypeChange: vi.fn(),
isEnableGroup: true,
handleGroupEnabledChange: vi.fn(),
handleAddGroup: vi.fn(),
handleListOrTypeChangeInGroup: vi.fn(() => vi.fn()),
handleGroupRemoved: vi.fn(() => vi.fn()),
handleVarGroupNameChange: vi.fn(() => vi.fn()),
isShowRemoveVarConfirm: false,
hideRemoveVarConfirm: vi.fn(),
onRemoveVarConfirm: vi.fn(),
getAvailableVars: vi.fn(() => []),
filterVar: vi.fn(() => vi.fn(() => true)),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('variable-assigner path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAvailableVars.mockReturnValue([
{
nodeId: 'source-node',
title: 'Source Node',
vars: [{ variable: 'pickedVar', type: VarType.string }],
},
])
mockUseConfig.mockReturnValue(createConfigResult())
vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
})
describe('Path Integration', () => {
it('should open the add-variable popup and assign a selected value', async () => {
const user = userEvent.setup()
const { container } = render(
<AddVariable
availableVars={[]}
variableAssignerNodeId="assigner-node"
variableAssignerNodeData={createData({ selected: true })}
handleId="group-1"
/>,
)
await user.click(container.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'confirm-add-variable' }))
expect(mockHandleAssignVariableValueChange).toHaveBeenCalledWith(
'assigner-node',
['source-node', 'pickedVar'],
{ variable: 'pickedVar', type: VarType.string },
'group-1',
)
})
it('should render node variable labels for env, system, and rag variables', () => {
const node = {
id: 'source-node',
data: { title: 'Source Node', type: BlockEnum.Answer },
} as any
const { rerender, container } = render(
<NodeVariableItem
node={node}
variable={['env', 'API_KEY']}
writeMode="append"
/>,
)
expect(container).toHaveTextContent('Source Node')
expect(container).toHaveTextContent('API_KEY')
expect(container).toHaveTextContent('workflow.nodes.assigner.operations.append')
rerender(
<NodeVariableItem
node={node}
variable={['sys', 'query']}
isException
/>,
)
expect(container).toHaveTextContent('sys.query')
rerender(
<NodeVariableItem
node={node}
variable={['rag', 'metadata']}
/>,
)
expect(container).toHaveTextContent('metadata')
})
it('should render, update, and remove variables in the list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
const { rerender } = render(
<VarList
readonly={false}
nodeId="assigner-node"
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('workflow.nodes.variableAssigner.noVarTip')).toBeInTheDocument()
rerender(
<VarList
readonly={false}
nodeId="assigner-node"
list={[['source-node', 'initialVar']]}
onChange={onChange}
onOpen={onOpen}
filterVar={vi.fn(() => true)}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-var' }))
expect(onOpen).toHaveBeenCalledWith(0)
expect(onChange).toHaveBeenLastCalledWith([['source-node', 'updatedVar']], ['source-node', 'updatedVar'])
await user.click(screen.getByRole('button', { name: 'remove-variable' }))
expect(onChange).toHaveBeenLastCalledWith([])
})
it('should add group variables, validate group names, and allow removing the group', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onGroupNameChange = vi.fn()
const onRemove = vi.fn()
const { container } = render(
<VarGroupItem
readOnly={false}
nodeId="assigner-node"
payload={{
group_name: 'Group1',
output_type: VarType.any,
variables: [],
}}
onChange={onChange}
groupEnabled
onGroupNameChange={onGroupNameChange}
canRemove
onRemove={onRemove}
availableVars={[]}
/>,
)
await user.click(screen.getByRole('button', { name: 'add-variable-from-picker' }))
expect(onChange).toHaveBeenCalledWith({
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'groupVar']],
})
await user.click(screen.getByText('Group1'))
fireEvent.change(screen.getByDisplayValue('Group1'), { target: { value: '1bad' } })
expect(Toast.notify).toHaveBeenCalled()
fireEvent.change(screen.getByDisplayValue('Group1'), { target: { value: 'Renamed Group' } })
expect(onGroupNameChange).toHaveBeenCalledWith('Renamed_Group')
await user.click(container.querySelector('.cursor-pointer.rounded-md') as HTMLElement)
expect(onRemove).toHaveBeenCalledTimes(1)
})
it('should ignore duplicate group variables and reset the output type when the group becomes empty', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<VarGroupItem
readOnly={false}
nodeId="assigner-node"
payload={{
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'groupVar']],
}}
onChange={onChange}
groupEnabled
availableVars={[]}
/>,
)
await user.click(screen.getByRole('button', { name: 'add-variable-from-picker' }))
expect(onChange).not.toHaveBeenCalled()
rerender(
<VarGroupItem
readOnly={false}
nodeId="assigner-node"
payload={{
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'updatedVar']],
}}
onChange={onChange}
groupEnabled
availableVars={[]}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-var' }))
expect(onChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'remove-variable' }))
expect(onChange).toHaveBeenLastCalledWith({
group_name: 'Group1',
output_type: VarType.any,
variables: [],
})
rerender(
<VarGroupItem
readOnly
nodeId="assigner-node"
payload={{
output_type: VarType.any,
variables: [],
}}
onChange={onChange}
groupEnabled={false}
availableVars={[]}
/>,
)
expect(screen.getByText('workflow.nodes.variableAssigner.title')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'add-variable-from-picker' })).not.toBeInTheDocument()
})
it('should render empty and populated node groups with hover states', async () => {
const user = userEvent.setup()
const selectedData = createData()
const { container, rerender } = renderWorkflowFlowComponent(
<NodeGroupItem
item={{
groupEnabled: true,
targetHandleId: 'group-1',
title: 'Group1',
type: 'string',
variables: [],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: selectedData,
}}
/>,
{
nodes: [
{ id: 'source-node', position: { x: 0, y: 0 }, data: { title: 'Source Node', type: BlockEnum.Answer } as any },
],
edges: [],
initialStoreState: {
enteringNodePayload: {
nodeId: 'assigner-node',
nodeData: selectedData,
} as any,
hoveringAssignVariableGroupId: 'group-1',
},
},
)
expect(container).toHaveTextContent('workflow.nodes.variableAssigner.varNotSet')
const groupCard = container.querySelector('.relative.rounded-lg') as HTMLElement
expect(groupCard).toHaveClass('!border-text-accent')
fireEvent.mouseEnter(groupCard)
fireEvent.mouseLeave(groupCard)
expect(mockHandleGroupItemMouseEnter).toHaveBeenCalledWith('group-1')
expect(mockHandleGroupItemMouseLeave).toHaveBeenCalledTimes(1)
rerender(
<NodeGroupItem
item={{
groupEnabled: true,
targetHandleId: 'group-2',
title: 'Group2',
type: 'string',
variables: [['source-node', 'initialVar']],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: selectedData,
}}
/>,
)
expect(container).toHaveTextContent('Source Node:source-node.initialVar:false')
expect(container.querySelector('.relative.rounded-lg')).toHaveClass('!border-dashed')
await user.click(container.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'confirm-add-variable' }))
expect(mockHandleAssignVariableValueChange).toHaveBeenCalled()
})
it('should resolve default group borders without an active hover id and render exception variables', () => {
const selectedData = createData()
const { container, rerender } = renderWorkflowFlowComponent(
<NodeGroupItem
item={{
groupEnabled: true,
targetHandleId: 'group-2',
title: 'Group2',
type: 'string',
variables: [],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: selectedData,
}}
/>,
{
nodes: [
{ id: 'agent-node', position: { x: 0, y: 0 }, data: { title: 'Agent Node', type: BlockEnum.Agent } as any },
],
edges: [],
initialStoreState: {
enteringNodePayload: {
nodeId: 'assigner-node',
nodeData: selectedData,
} as any,
hoveringAssignVariableGroupId: undefined,
},
},
)
expect(container.querySelector('.relative.rounded-lg')).toHaveClass('!border-dashed')
rerender(
<NodeGroupItem
item={{
groupEnabled: false,
targetHandleId: 'target',
title: 'Target',
type: 'string',
variables: [['agent-node', 'error_message']],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: createData({
output_type: VarType.any,
variables: [['agent-node', 'error_message']],
}),
}}
/>,
)
expect(container).toHaveTextContent('Agent Node:agent-node.error_message:true')
})
it('should render grouped and ungrouped panels and confirm removal actions', async () => {
const user = userEvent.setup()
const groupedConfig = createConfigResult({
isShowRemoveVarConfirm: true,
})
mockUseConfig.mockReturnValue(groupedConfig)
const { rerender } = render(
<Panel
id="assigner-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.getByText('Group1.output:string:workflow.nodes.variableAssigner.outputVars.varDescribe:{"groupName":"Group1"}')).toBeInTheDocument()
expect(screen.getByText('Group2.output:number:workflow.nodes.variableAssigner.outputVars.varDescribe:{"groupName":"Group2"}')).toBeInTheDocument()
await user.click(screen.getByRole('switch'))
expect(groupedConfig.handleGroupEnabledChange).toHaveBeenCalled()
await user.click(screen.getByText('workflow.nodes.variableAssigner.addGroup'))
expect(groupedConfig.handleAddGroup).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'cancel-remove' }))
expect(groupedConfig.hideRemoveVarConfirm).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'confirm-remove' }))
expect(groupedConfig.onRemoveVarConfirm).toHaveBeenCalledTimes(1)
const singleConfig = createConfigResult({
isEnableGroup: false,
inputs: createData({
advanced_settings: {
group_enabled: false,
groups: [],
},
}),
})
mockUseConfig.mockReturnValue(singleConfig)
rerender(
<Panel
id="assigner-node"
data={singleConfig.inputs}
panelProps={panelProps}
/>,
)
expect(screen.queryByText('Group1.output:string:workflow.nodes.variableAssigner.outputVars.varDescribe:{"groupName":"Group1"}')).not.toBeInTheDocument()
expect(screen.getByText('workflow.nodes.variableAssigner.aggregationGroup')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,162 @@
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CUSTOM_NODE } from '@/app/components/workflow/constants'
import { DeliveryMethodType, UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType } from '@/app/components/workflow/types'
import HumanInputFormList from '../human-input-form-list'
const mockNodes: Array<{
id: string
type: string
data: {
delivery_methods: Array<Record<string, unknown>>
}
}> = []
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => mockNodes,
}),
}),
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { userProfile: { email: string } }) => T) => selector({
userProfile: { email: 'debug@example.com' },
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
form_id: 'form-1',
node_id: 'human-node-1',
node_title: 'Need Approval',
form_content: 'Before {{#$output.reason#}} after',
inputs: [{
type: InputVarType.paragraph,
output_variable_name: 'reason',
default: {
selector: [],
type: 'constant',
value: 'prefill',
},
}],
actions: [{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}],
form_token: 'token-1',
resolved_default_values: {},
display_in_ui: true,
expiration_time: 2_000_000_000,
...overrides,
})
describe('HumanInputFormList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodes.splice(0, mockNodes.length)
})
it('should render only visible forms, derive delivery method tips, and submit updated inputs', async () => {
const user = userEvent.setup()
const onHumanInputFormSubmit = vi.fn().mockResolvedValue(undefined)
mockNodes.push(
{
id: 'human-node-1',
type: CUSTOM_NODE,
data: {
delivery_methods: [{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
config: {
recipients: {
whole_workspace: false,
items: [],
},
subject: 'Need approval',
body: 'Please review',
debug_mode: true,
},
}],
},
},
{
id: 'human-node-2',
type: CUSTOM_NODE,
data: {
delivery_methods: [],
},
},
)
render(
<HumanInputFormList
humanInputFormDataList={[
createFormData(),
createFormData({
form_id: 'form-2',
node_id: 'human-node-2',
node_title: 'Hidden Form',
display_in_ui: false,
}),
]}
onHumanInputFormSubmit={onHumanInputFormSubmit}
/>,
)
expect(screen.getByText('Need Approval')).toBeInTheDocument()
expect(screen.queryByText('Hidden Form')).not.toBeInTheDocument()
expect(screen.getByDisplayValue('prefill')).toBeInTheDocument()
expect(screen.getByTestId('expiration-time')).toBeInTheDocument()
expect(screen.getByTestId('tips')).toBeInTheDocument()
await user.clear(screen.getByDisplayValue('prefill'))
await user.type(screen.getByTestId('content-item-textarea'), 'updated reason')
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(onHumanInputFormSubmit).toHaveBeenCalledWith('token-1', {
inputs: {
reason: 'updated reason',
},
action: 'approve',
})
})
it('should omit delivery tips when the node has no enabled delivery methods', () => {
mockNodes.push({
id: 'human-node-1',
type: CUSTOM_NODE,
data: {
delivery_methods: [],
},
})
render(
<HumanInputFormList
humanInputFormDataList={[
createFormData(),
]}
/>,
)
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
})
it('should render an empty container when there are no visible forms', () => {
render(
<HumanInputFormList
humanInputFormDataList={[]}
/>,
)
expect(screen.queryByTestId('content-wrapper')).not.toBeInTheDocument()
})
})

View File

@ -1,115 +1,252 @@
import type { PanelProps } from '../index'
import { screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Panel from '../index'
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
class MockResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
type MockNodeData = {
selected?: boolean
title?: string
}
vi.mock('@/next/dynamic', () => ({
default: () => (props: { latestVersionId?: string }) => {
mockVersionHistoryPanel(props)
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
},
type MockNode = {
id: string
type: string
data: MockNodeData
}
type MockPanelStoreState = {
showEnvPanel: boolean
isRestoring: boolean
showWorkflowVersionHistoryPanel: boolean
workflowCanvasWidth: number
previewPanelWidth: number
setPreviewPanelWidth: (value: number) => void
setRightPanelWidth: (value: number) => void
setOtherPanelWidth: (value: number) => void
}
type MockResizeMode = 'borderBox' | 'contentRect' | 'fallback'
let mockResizeModes: MockResizeMode[] = []
let mockResizeObservers: MockResizeObserver[] = []
const createResizeEntry = (mode: MockResizeMode): ResizeObserverEntry => ({
borderBoxSize: mode === 'borderBox'
? [{ inlineSize: 720, blockSize: 0 }] as ResizeObserverSize[]
: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
contentRect: {
width: mode === 'contentRect' ? 530 : 0,
height: 0,
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ({}),
} as DOMRectReadOnly,
target: document.createElement('div'),
} as unknown as ResizeObserverEntry)
class MockResizeObserver {
callback: ResizeObserverCallback
observe = vi.fn(() => {
if (!mockResizeModes.length)
return
this.callback(
mockResizeModes.map(createResizeEntry),
this as unknown as ResizeObserver,
)
})
disconnect = vi.fn()
unobserve = vi.fn()
constructor(callback: ResizeObserverCallback) {
this.callback = callback
mockResizeObservers.push(this)
}
}
let mockNodes: MockNode[] = []
let mockPanelStoreState: MockPanelStoreState
vi.mock('reactflow', () => ({
useStore: (selector: (state: { getNodes: () => MockNode[] }) => unknown) => selector({
getNodes: () => mockNodes,
}),
useStoreApi: () => ({
getState: () => ({
getNodes: () => mockNodes,
setNodes: vi.fn(),
}),
}),
}))
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStore: vi.fn(selector => selector({
getNodes: () => mod.rfState.nodes,
})),
}
})
vi.mock('../env-panel', () => ({
default: () => <div data-testid="env-panel" />,
vi.mock('../../store', () => ({
useStore: <T,>(selector: (state: MockPanelStoreState) => T) => selector(mockPanelStoreState),
}))
vi.mock('../../nodes', () => ({
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
Panel: ({ id, data }: { id: string, data: MockNodeData }) => (
<div data-testid="node-panel">{`${id}:${data.title || 'untitled'}`}</div>
),
}))
const versionHistoryPanelProps = {
latestVersionId: 'version-1',
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
vi.mock('@/app/components/workflow/panel/env-panel', () => ({
default: () => <div data-testid="env-panel">env-panel</div>,
}))
vi.mock('@/app/components/workflow/panel/version-history-panel', () => ({
default: ({ latestVersionId }: { latestVersionId?: string }) => (
<div data-testid="version-history-panel">{latestVersionId || 'none'}</div>
),
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
describe('Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
beforeAll(() => {
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
beforeEach(() => {
vi.clearAllMocks()
mockNodes = []
mockResizeModes = []
mockResizeObservers = []
mockPanelStoreState = {
showEnvPanel: false,
isRestoring: false,
showWorkflowVersionHistoryPanel: false,
workflowCanvasWidth: 0,
previewPanelWidth: 420,
setPreviewPanelWidth: vi.fn(),
setRightPanelWidth: vi.fn(),
setOtherPanelWidth: vi.fn(),
}
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
width: 640,
height: 320,
top: 0,
right: 640,
bottom: 320,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}))
})
afterEach(() => {
vi.restoreAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
})
describe('Version History Panel', () => {
it('should render the version history panel when the panel is open and props are provided', () => {
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
it('should render slots, selected node details, and secondary panels while constraining oversized preview widths', async () => {
mockNodes = [{
id: 'node-1',
type: 'custom',
data: {
selected: true,
title: 'Selected Node',
},
}]
mockPanelStoreState = {
...mockPanelStoreState,
showEnvPanel: true,
showWorkflowVersionHistoryPanel: true,
workflowCanvasWidth: 1000,
previewPanelWidth: 520,
}
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
latestVersionId: 'version-1',
}))
})
render(
<Panel
components={{
left: <div>left-slot</div>,
right: <div>right-slot</div>,
}}
versionHistoryPanelProps={{
latestVersionId: 'version-1',
restoreVersionUrl: versionId => `/apps/app-1/workflows/${versionId}/restore`,
}}
/>,
)
it('should not render the version history panel when the panel is open but props are missing', () => {
renderWorkflowComponent(
<Panel />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.getByText('left-slot')).toBeInTheDocument()
expect(screen.getByText('right-slot')).toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('node-1:Selected Node')
expect(screen.getByTestId('env-panel')).toBeInTheDocument()
expect(await screen.findByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockPanelStoreState.setPreviewPanelWidth).toHaveBeenCalledWith(400)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640)
})
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
})
it('should skip node and auxiliary panels when there is no selected node or open side panel state', () => {
render(
<Panel
components={{
left: <div>left-only</div>,
}}
/>,
)
it('should not render the version history panel when the panel is closed', () => {
rfState.nodes = [
createNode({
id: 'selected-node',
data: {
selected: true,
},
}),
] as typeof rfState.nodes
expect(screen.getByText('left-only')).toBeInTheDocument()
expect(screen.queryByTestId('node-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('env-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockPanelStoreState.setPreviewPanelWidth).not.toHaveBeenCalled()
})
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: false,
},
},
)
it('should derive observer widths from border-box, content-rect, and fallback values and disconnect on unmount', () => {
mockResizeModes = ['borderBox', 'contentRect', 'fallback']
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
})
const { unmount } = render(<Panel />)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(720)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(530)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(720)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(530)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640)
unmount()
expect(mockResizeObservers).toHaveLength(2)
mockResizeObservers.forEach(observer => expect(observer.disconnect).toHaveBeenCalledTimes(1))
})
})

View File

@ -0,0 +1,354 @@
import type { Shape } from '../../store/workflow'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import { toast } from '@/app/components/base/ui/toast'
import { createNodeTracing, createWorkflowRunningData } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { submitHumanInputForm } from '@/service/workflow'
import WorkflowPreview from '../workflow-preview'
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
},
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
vi.mock('@/app/components/workflow/run/result-panel', () => ({
default: ({ status }: { status?: string }) => <div data-testid="result-panel">{status}</div>,
}))
vi.mock('@/app/components/workflow/run/result-text', () => ({
default: ({
outputs,
isPaused,
isRunning,
onClick,
}: {
outputs?: string
isPaused?: boolean
isRunning?: boolean
onClick?: () => void
}) => (
<div>
<div data-testid="result-text">{JSON.stringify({ outputs, isPaused, isRunning })}</div>
<button type="button" onClick={onClick}>open-detail</button>
</div>
),
}))
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: unknown[] }) => <div data-testid="tracing-panel">{list.length}</div>,
}))
vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({
default: ({ onRun }: { onRun: () => void }) => (
<button type="button" onClick={onRun}>
run-inputs
</button>
),
}))
vi.mock('@/app/components/workflow/panel/human-input-form-list', () => ({
default: ({
humanInputFormDataList,
onHumanInputFormSubmit,
}: {
humanInputFormDataList: unknown[]
onHumanInputFormSubmit?: (token: string, formData: Record<string, string>) => Promise<void>
}) => (
<div>
<div data-testid="human-form-list">{humanInputFormDataList.length}</div>
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { answer: 'ok' })}>
submit-human-form
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/human-input-filled-form-list', () => ({
default: ({ humanInputFilledFormDataList }: { humanInputFilledFormDataList: unknown[] }) => (
<div data-testid="filled-form-list">{humanInputFilledFormDataList.length}</div>
),
}))
const mockCopy = vi.mocked(copy)
const mockToastSuccess = vi.mocked(toast.success)
const mockSubmitHumanInputForm = vi.mocked(submitHumanInputForm)
type WorkflowResult = NonNullable<ReturnType<typeof createWorkflowRunningData>['result']>
const createWorkflowResult = (overrides: Partial<WorkflowResult> = {}): WorkflowResult => ({
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
...overrides,
})
const createHumanInputFormData = (
overrides: Partial<HumanInputFormData> = {},
): HumanInputFormData => ({
form_id: 'form-1',
node_id: 'human-node-1',
node_title: 'Need Approval',
form_content: 'Before {{#$output.reason#}} after',
inputs: [],
actions: [],
form_token: 'token-1',
resolved_default_values: {},
display_in_ui: true,
expiration_time: 2_000_000_000,
...overrides,
})
const createHumanInputFilledFormData = (
overrides: Partial<HumanInputFilledFormData> = {},
): HumanInputFilledFormData => ({
node_id: 'node-1',
node_title: 'Need Approval',
rendered_content: 'rendered',
action_id: 'approve',
action_text: 'Approve',
...overrides,
})
describe('WorkflowPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1200,
})
})
it('should keep the input tab active, switch to result after running, and close the preview panel', async () => {
const user = userEvent.setup()
const { container } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
showInputsPanel: true,
showDebugAndPreviewPanel: true,
previewPanelWidth: 420,
},
},
)
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'run-inputs' }))
expect(screen.getByTestId('result-text')).toBeInTheDocument()
await user.click(container.querySelector('.flex.items-center.justify-between .cursor-pointer.p-1') as HTMLElement)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('should switch to detail when the workflow is listening', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
isListening: true,
workflowRunningData: createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Running,
}),
}),
},
},
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Running)
})
it('should switch to detail when a finished run has no outputs or files', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Succeeded,
files: [],
}),
}),
resultText: '',
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Succeeded)
})
it('should render paused human input results and submit pending forms', async () => {
const user = userEvent.setup()
const pausedData = createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Paused,
files: [],
}),
humanInputFormDataList: [createHumanInputFormData()],
humanInputFilledFormDataList: [createHumanInputFilledFormData()],
})
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: pausedData,
},
},
)
expect(screen.getByTestId('human-form-list')).toHaveTextContent('1')
expect(screen.getByTestId('filled-form-list')).toHaveTextContent('1')
await user.click(screen.getByRole('button', { name: 'submit-human-form' }))
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
})
it('should copy successful string output and show a success toast', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Succeeded,
files: [],
}),
}),
resultText: 'final answer',
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
await user.click(screen.getByText('runLog.result'))
await user.click(screen.getByRole('button', { name: 'common.operation.copy' }))
expect(mockCopy).toHaveBeenCalledWith('final answer')
expect(mockToastSuccess).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
})
it('should show a loading state for an empty detail panel', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
isListening: true,
workflowRunningData: undefined,
},
},
)
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('should show a loading state for an empty tracing panel', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: createWorkflowRunningData({
tracing: [],
}),
},
},
)
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('0')
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('should keep inert tabs disabled without run data and switch among result, detail, and tracing when data exists', async () => {
const user = userEvent.setup()
const { store } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
showInputsPanel: true,
workflowRunningData: undefined,
},
},
)
await user.click(screen.getByText('runLog.result'))
await user.click(screen.getByText('runLog.detail'))
await user.click(screen.getByText('runLog.tracing'))
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
store.setState({
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Succeeded,
files: [],
}),
tracing: [createNodeTracing()],
}),
resultText: 'ready',
} as NonNullable<Shape['workflowRunningData']>,
})
await user.click(screen.getByText('runLog.result'))
expect(screen.getByTestId('result-text')).toBeInTheDocument()
await user.click(screen.getByText('runLog.detail'))
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
await user.click(screen.getByText('runLog.tracing'))
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('1')
await user.click(screen.getByText('runLog.result'))
await user.click(screen.getByRole('button', { name: 'open-detail' }))
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
it('should resize the preview panel within the allowed workflow canvas bounds', async () => {
const { container, store } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
previewPanelWidth: 450,
workflowCanvasWidth: 1000,
},
},
)
const resizeHandle = container.querySelector('.cursor-col-resize') as HTMLElement
fireEvent.mouseDown(resizeHandle)
fireEvent.mouseMove(window, { clientX: 700 })
fireEvent.mouseMove(window, { clientX: 100 })
fireEvent.mouseUp(window)
await waitFor(() => {
expect(store.getState().previewPanelWidth).toBe(500)
})
})
})

View File

@ -0,0 +1,176 @@
import type { ChatItemInTree } from '@/app/components/base/chat/types'
import type { HistoryWorkflowData } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import ChatRecord from '../index'
import UserInput from '../user-input'
const mockFetchConversationMessages = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
vi.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFilesFromResponse: (files: Array<{ id: string }>) => files.map(file => ({ ...file, processed: true })),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowRun: () => ({
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: ({
chatList,
chatNode,
switchSibling,
}: {
chatList: ChatItemInTree[]
chatNode: React.ReactNode
switchSibling: (messageId: string) => void
}) => (
<div>
<button type="button" onClick={() => switchSibling('msg-2')}>
switch sibling
</button>
<div data-testid="chat-node">{chatNode}</div>
{chatList.map(item => (
<div key={item.id}>{`${item.content}:files-${item.message_files?.length ?? 0}`}</div>
))}
</div>
),
}))
const historyWorkflowData: HistoryWorkflowData = {
id: 'run-1',
status: 'succeeded',
conversation_id: 'conversation-1',
finished_at: 1_700_000_000,
}
describe('ChatRecord integration', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
})
it('should render fetched chat history and switch sibling threads', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{
id: 'msg-1',
query: 'Question 1',
answer: 'Answer 1',
metadata: {},
message_files: [
{ id: 'user-file-1', belongs_to: 'user' },
{ id: 'assistant-file-1', belongs_to: 'assistant' },
],
},
{ id: 'msg-2', query: 'Question 2', answer: 'Answer 2', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
{ id: 'msg-3', query: 'Question 3', answer: 'Answer 3', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
],
})
renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await waitFor(() => {
expect(mockFetchConversationMessages).toHaveBeenCalledWith('app-1', 'conversation-1')
})
expect(screen.getByText('Question 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Answer 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Question 3:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 3:files-0')).toBeInTheDocument()
expect(screen.queryByText('Question 2:files-0')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'switch sibling' }))
expect(screen.getByText('Question 2:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 2:files-0')).toBeInTheDocument()
expect(screen.queryByText('Question 3:files-0')).not.toBeInTheDocument()
})
it('should close the record panel and restore the backup draft', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{ id: 'msg-1', query: 'Question 1', answer: 'Answer 1', metadata: {}, message_files: [] },
],
})
const { container, store } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await screen.findByText('Question 1:files-0')
const closeButton = container.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement
await user.click(closeButton)
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().historyWorkflowData).toBeUndefined()
})
it('should stop loading even when conversation fetch fails', async () => {
mockFetchConversationMessages.mockRejectedValue(new Error('network error'))
const { container } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await waitFor(() => {
expect(container).toHaveTextContent('TEST CHAT')
})
expect(screen.queryByText('Question 1')).not.toBeInTheDocument()
})
it('should render no user-input block when the variable list is empty', () => {
const { container } = render(<UserInput />)
expect(container.firstChild).toBeNull()
})
it('should render provided user-input variables and toggle the panel body', async () => {
const user = userEvent.setup()
const { container } = render(
<UserInput
variables={[
{ variable: 'query' },
{ variable: 'locale' },
]}
initialExpanded={false}
/>,
)
const header = screen.getByText('WORKFLOW.PANEL.USERINPUTFIELD')
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(2)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
})
})

View File

@ -5,10 +5,21 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
const UserInput = () => {
type UserInputVariable = {
variable: string
}
type UserInputProps = {
variables?: UserInputVariable[]
initialExpanded?: boolean
}
const UserInput = ({
variables = [],
initialExpanded = true,
}: UserInputProps) => {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(true)
const variables: any = []
const [expanded, setExpanded] = useState(initialExpanded)
if (!variables.length)
return null

View File

@ -0,0 +1,262 @@
import type { ConversationVariable, Node } from '@/app/components/workflow/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ChatVariablePanel from '../index'
import { ChatVarType } from '../type'
type MockWorkflowStoreState = {
setShowChatVariablePanel: (value: boolean) => void
conversationVariables: ConversationVariable[]
setConversationVariables: (value: ConversationVariable[]) => void
}
type MockFlowStore = {
getNodes: () => Node[]
setNodes: (nodes: Node[]) => void
}
const mockSetShowChatVariablePanel = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn((_sync: boolean, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
const mockInvalidateConversationVarValues = vi.fn()
const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>()
const mockUpdateNodeVars = vi.fn<(node: Node, current: string[], next: string[]) => Node>()
let mockConversationVariables: ConversationVariable[] = []
let mockFlowNodes: Node[] = []
const mockSetNodes = vi.fn<(nodes: Node[]) => void>()
const createConversationVariable = (
overrides: Partial<ConversationVariable> = {},
): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
value_type: ChatVarType.String,
value: '',
description: 'Conversation variable',
...overrides,
})
const createNode = (id: string): Node => ({
id,
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: id,
desc: '',
type: 'llm' as Node['data']['type'],
},
})
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: (): MockFlowStore => ({
getNodes: () => mockFlowNodes,
setNodes: mockSetNodes,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => selector({
setShowChatVariablePanel: mockSetShowChatVariablePanel,
conversationVariables: mockConversationVariables,
setConversationVariables: mockSetConversationVariables,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
vi.mock('../../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
invalidateConversationVarValues: mockInvalidateConversationVarValues,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
findUsedVarNodes: (...args: Parameters<typeof mockFindUsedVarNodes>) => mockFindUsedVarNodes(...args),
updateNodeVars: (...args: Parameters<typeof mockUpdateNodeVars>) => mockUpdateNodeVars(...args),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-item', () => ({
default: ({
item,
onEdit,
onDelete,
}: {
item: ConversationVariable
onEdit: (item: ConversationVariable) => void
onDelete: (item: ConversationVariable) => void
}) => (
<div>
<span>{item.name}</span>
<button type="button" onClick={() => onEdit(item)}>{`edit-${item.name}`}</button>
<button type="button" onClick={() => onDelete(item)}>{`delete-${item.name}`}</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger', () => ({
default: ({
open,
showTip,
chatVar,
onSave,
onClose,
}: {
open: boolean
showTip: boolean
chatVar?: ConversationVariable
onSave: (chatVar: ConversationVariable) => void
onClose: () => void
}) => (
<div data-testid="variable-modal-trigger">
<span>{open ? 'open' : 'closed'}</span>
<span>{showTip ? 'tip-on' : 'tip-off'}</span>
<span>{chatVar?.name || 'new-variable'}</span>
<button
type="button"
onClick={() => onSave({
id: 'var-added',
name: 'fresh_var',
value_type: ChatVarType.String,
value: '',
description: 'Added variable',
})}
>
save-add
</button>
{chatVar && (
<button
type="button"
onClick={() => onSave({
...chatVar,
name: `${chatVar.name}_next`,
})}
>
save-edit
</button>
)}
<button type="button" onClick={onClose}>close-trigger</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
}) => {
if (!isShow)
return null
return (
<div data-testid="remove-effect-var-confirm">
<button type="button" onClick={onConfirm}>confirm-remove</button>
<button type="button" onClick={onCancel}>cancel-remove</button>
</div>
)
},
}))
describe('ChatVariablePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConversationVariables = [createConversationVariable()]
mockFlowNodes = [createNode('node-1'), createNode('node-2')]
mockFindUsedVarNodes.mockReturnValue([])
mockUpdateNodeVars.mockImplementation((node: Node) => node)
})
it('should toggle the tips area and close the panel', async () => {
const user = userEvent.setup()
const { container } = render(<ChatVariablePanel />)
expect(screen.getByText('workflow.chatVariable.panelDescription')).toBeInTheDocument()
const toggleTipButton = screen.getAllByRole('button')[0]!
await user.click(toggleTipButton)
expect(screen.queryByText('workflow.chatVariable.panelDescription')).not.toBeInTheDocument()
const closeButton = container.querySelector('.flex.h-6.w-6.cursor-pointer.items-center.justify-center') as HTMLElement
await user.click(closeButton)
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
})
it('should prepend newly added variables and sync the workflow draft', async () => {
const user = userEvent.setup()
render(<ChatVariablePanel />)
await user.click(screen.getByRole('button', { name: 'save-add' }))
await waitFor(() => {
expect(mockSetConversationVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'var-added', name: 'fresh_var' }),
createConversationVariable(),
])
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockInvalidateConversationVarValues).toHaveBeenCalledTimes(1)
})
it('should rename existing variables and update affected node references', async () => {
const user = userEvent.setup()
const effectedNode = createNode('node-1')
const updatedNode = createNode('node-1-updated')
mockFindUsedVarNodes.mockReturnValue([effectedNode])
mockUpdateNodeVars.mockReturnValue(updatedNode)
render(<ChatVariablePanel />)
await user.click(screen.getByRole('button', { name: 'edit-conversation_var' }))
await user.click(screen.getByRole('button', { name: 'save-edit' }))
expect(mockSetConversationVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'var-1', name: 'conversation_var_next' }),
])
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
effectedNode,
['conversation', 'conversation_var'],
['conversation', 'conversation_var_next'],
)
expect(mockSetNodes).toHaveBeenCalledWith([updatedNode, createNode('node-2')])
})
it('should require confirmation before deleting variables referenced by workflow nodes', async () => {
const user = userEvent.setup()
const effectedNode = createNode('node-1')
const prunedNode = createNode('node-1-pruned')
mockFindUsedVarNodes.mockReturnValue([effectedNode])
mockUpdateNodeVars.mockReturnValue(prunedNode)
render(<ChatVariablePanel />)
await user.click(screen.getByRole('button', { name: 'delete-conversation_var' }))
expect(screen.getByTestId('remove-effect-var-confirm')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'confirm-remove' }))
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
effectedNode,
['conversation', 'conversation_var'],
[],
)
expect(mockSetNodes).toHaveBeenCalledWith([prunedNode, createNode('node-2')])
expect(mockSetConversationVariables).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,282 @@
/* eslint-disable ts/no-explicit-any */
import type { ConversationVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import ArrayBoolList from '../array-bool-list'
import ArrayValueList from '../array-value-list'
import VariableItem from '../variable-item'
import VariableModalTrigger from '../variable-modal-trigger'
import VariableTypeSelector from '../variable-type-select'
vi.mock('../variable-modal', () => ({
default: ({ chatVar, onSave, onClose }: any) => (
<div>
{chatVar?.name && <div>{chatVar.name}</div>}
<button type="button" onClick={() => onSave({ id: 'saved' })}>save-modal</button>
<button type="button" onClick={onClose}>close-modal</button>
</div>
),
}))
const createVariable = (overrides: Partial<ConversationVariable> = {}): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
description: 'Conversation scoped variable',
value_type: ChatVarType.String,
value: '',
...overrides,
})
describe('chat-variable-panel components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The panel leaf components should support editing, selecting types, and opening the add-variable modal.
describe('Leaf interactions', () => {
it('should update string array items, add rows, and remove rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString
list={['alpha', 'beta']}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('alpha'), { target: { value: 'updated' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
await user.click(screen.getAllByRole('button')[0]!)
expect(onChange).toHaveBeenCalledTimes(3)
})
it('should coerce number array items and append undefined rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString={false}
list={[1]}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '7' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(1, [7])
expect(onChange).toHaveBeenNthCalledWith(2, [1, undefined])
})
it('should call edit and delete handlers from the variable item actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const { container } = render(
<VariableItem
item={createVariable()}
onEdit={onEdit}
onDelete={onDelete}
/>,
)
const card = container.firstElementChild as HTMLDivElement
const actions = container.querySelectorAll('.cursor-pointer')
fireEvent.mouseOver(actions[1] as Element)
expect(card.className).toContain('border-state-destructive-border')
fireEvent.mouseOut(actions[1] as Element)
expect(card.className).not.toContain('border-state-destructive-border')
const icons = container.querySelectorAll('svg')
await user.click(icons[1] as SVGElement)
await user.click(icons[2] as SVGElement)
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
})
it('should toggle the type selector and select a new value', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
value="string"
list={['string', 'number', 'boolean']}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText('string'))
await user.click(screen.getByText('number'))
expect(onSelect).toHaveBeenCalledWith('number')
})
it('should dismiss the type selector through the real portal close flow', async () => {
const user = userEvent.setup()
render(
<VariableTypeSelector
value="string"
list={['string', 'number']}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText('string'))
expect(screen.getByText('number')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('number')).not.toBeInTheDocument()
})
})
it('should open the in-cell selector from its trigger and keep the popup class', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
inCell
value="string"
list={['string', 'number']}
popupClassName="custom-popup"
onSelect={onSelect}
/>,
)
await user.click(screen.getAllByText('string')[0]!)
expect(screen.getByText('number').closest('.custom-popup')).not.toBeNull()
await user.click(screen.getAllByText('string')[1]!)
expect(onSelect).toHaveBeenCalledWith('string')
})
it('should update, add, and remove boolean array values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ArrayBoolList
list={[true]}
onChange={onChange}
/>,
)
await user.click(screen.getByText('False'))
expect(onChange).toHaveBeenNthCalledWith(1, [false])
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(2, [true, false])
const buttons = container.querySelectorAll('button')
await user.click(buttons[0] as HTMLButtonElement)
expect(onChange).toHaveBeenNthCalledWith(3, [])
})
it('should toggle the modal trigger without closing when it starts closed', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
render(
<VariableModalTrigger
open={false}
setOpen={setOpen}
showTip
onClose={onClose}
onSave={vi.fn()}
/>,
)
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.chatVariable.button'))
expect(setOpen).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
})
it('should open the modal trigger and close after saving', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
const onSave = vi.fn()
render(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={onSave}
/>,
)
expect(screen.getByText('conversation_var')).toBeInTheDocument()
await user.click(screen.getByText('save-modal'))
await user.click(screen.getByText('close-modal'))
expect(onSave).toHaveBeenCalledWith({ id: 'saved' })
expect(onClose).toHaveBeenCalled()
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close the modal trigger when clicking the trigger while already open', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
render(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.chatVariable.button' }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(setOpen).toHaveBeenCalled()
})
it('should close the modal trigger when the portal dismisses', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(true)
return (
<VariableModalTrigger
open={open}
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
render(<TriggerHarness />)
expect(screen.getByText('save-modal')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
})
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,610 @@
import type { ChatWrapperRefType } from '../index'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import { useStore as useAppStore } from '@/app/components/app/store'
import { createStartNode } from '@/app/components/workflow/__tests__/fixtures'
import {
renderWorkflowComponent,
renderWorkflowFlowComponent,
} from '@/app/components/workflow/__tests__/workflow-test-env'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import { InputVarType } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { fetchSuggestedQuestions, stopChatMessageResponding } from '@/service/debug'
import { fetchCurrentValueOfConversationVariable } from '@/service/workflow'
import ChatWrapper from '../chat-wrapper'
import ConversationVariableModal from '../conversation-variable-modal'
import UserInput from '../user-input'
const mockUseChat = vi.fn()
const mockChatRender = vi.fn()
const mockUseSubscription = vi.fn()
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
vi.mock('@/service/debug', () => ({
fetchSuggestedQuestions: vi.fn(),
stopChatMessageResponding: vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
fetchCurrentValueOfConversationVariable: vi.fn(),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => `formatted-${timestamp}`,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value }: { value?: string }) => <pre data-testid="conversation-code-editor">{value}</pre>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/form-item', () => ({
default: ({
payload,
value,
onChange,
}: {
payload: { label?: string, variable: string }
value?: string
onChange: (value: string) => void
}) => (
<input
aria-label={payload.label || payload.variable}
value={value ?? ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: ({
chatNode,
inputDisabled,
onSend,
onRegenerate,
switchSibling,
onHumanInputFormSubmit,
onFeatureBarClick,
}: {
chatNode: React.ReactNode
inputDisabled?: boolean
onSend?: (message: string, files: unknown[]) => void
onRegenerate?: (chatItem: { id: string, parentMessageId?: string, content?: string, message_files?: unknown[] }) => void
switchSibling?: (siblingMessageId: string) => void
onHumanInputFormSubmit?: (formToken: string, formData: Record<string, string>) => Promise<void>
onFeatureBarClick?: (state: boolean) => void
}) => {
mockChatRender({
inputDisabled,
hasChatNode: !!chatNode,
})
return (
<div data-testid="chat-shell">
<div data-testid="chat-input-disabled">{`${inputDisabled}`}</div>
<button type="button" onClick={() => onSend?.('hello', [])}>send-chat</button>
<button
type="button"
onClick={() => onRegenerate?.({
id: 'answer-2',
parentMessageId: 'question-1',
content: 'latest answer',
message_files: [],
})}
>
regenerate-chat
</button>
<button type="button" onClick={() => switchSibling?.('sibling-2')}>switch-sibling</button>
<button type="button" onClick={() => onHumanInputFormSubmit?.('token-1', { answer: 'ok' })}>submit-human-input</button>
<button type="button" onClick={() => onFeatureBarClick?.(true)}>open-feature-panel</button>
{chatNode}
</div>
)
},
}))
vi.mock('../hooks', () => ({
useChat: (...args: unknown[]) => mockUseChat(...args),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: <T,>(selector: (state: {
features: {
opening?: { enabled?: boolean, opening_statement?: string, suggested_questions?: string[] }
suggested: boolean
text2speech: boolean
speech2text: boolean
citation: boolean
moderation: boolean
file: { enabled: boolean }
}
}) => T) => selector({
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: false,
text2speech: false,
speech2text: false,
citation: false,
moderation: false,
file: { enabled: false },
},
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: mockUseSubscription,
},
}),
}))
const mockFetchCurrentValueOfConversationVariable = vi.mocked(fetchCurrentValueOfConversationVariable)
const mockCopy = vi.mocked(copy)
const mockFetchSuggestedQuestions = vi.mocked(fetchSuggestedQuestions)
const mockStopChatMessageResponding = vi.mocked(stopChatMessageResponding)
const createConversationVariable = (
overrides: Partial<ConversationVariable> = {},
): ConversationVariable => ({
id: 'var-1',
name: 'session_state',
description: 'Session state',
value_type: ChatVarType.Object,
value: '{"draft":true}',
...overrides,
})
const createChatState = (overrides: Record<string, unknown> = {}) => ({
conversationId: 'conversation-1',
chatList: [],
handleStop: vi.fn(),
isResponding: false,
suggestedQuestions: [],
handleSend: vi.fn(),
handleRestart: vi.fn(),
handleSwitchSibling: vi.fn(),
handleSubmitHumanInputForm: vi.fn(),
getHumanInputNodeData: vi.fn(),
...overrides,
})
const createConversationVariableResponse = (
data: Array<Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>>['data'][number]> = [],
): Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>> => ({
data,
has_more: false,
limit: 20,
total: data.length,
page: 1,
})
const createChatWrapperRef = () => ({ current: null }) as unknown as React.RefObject<ChatWrapperRefType>
describe('debug-and-preview components', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: {
id: 'app-1',
site: {
access_token: 'site-token',
app_base_url: 'https://example.com',
},
} as ReturnType<typeof useAppStore.getState>['appDetail'],
})
mockUseChat.mockReturnValue(createChatState())
mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse())
})
afterEach(() => {
vi.useRealTimers()
})
describe('ConversationVariableModal', () => {
it('should load latest values, switch variable tabs, and close the modal', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([
{
...createConversationVariable({
id: 'var-1',
value: '{"latest":1}',
}),
updated_at: 100,
created_at: 50,
},
{
...createConversationVariable({
id: 'var-2',
name: 'summary',
value_type: ChatVarType.String,
value: 'latest text',
}),
updated_at: 200,
created_at: 150,
},
]))
renderWorkflowComponent(
<ConversationVariableModal
conversationID="conversation-1"
onHide={onHide}
/>,
{
initialStoreState: {
appId: 'app-1',
conversationVariables: [
createConversationVariable(),
createConversationVariable({
id: 'var-2',
name: 'summary',
value_type: ChatVarType.String,
value: 'plain text',
}),
],
},
},
)
await waitFor(() => {
expect(mockFetchCurrentValueOfConversationVariable).toHaveBeenCalledWith({
url: '/apps/app-1/conversation-variables',
params: { conversation_id: 'conversation-1' },
})
})
expect(screen.getAllByText('session_state')).toHaveLength(2)
expect(screen.getByText(content => content.includes('formatted-100'))).toBeInTheDocument()
expect(screen.getByTestId('conversation-code-editor')).toHaveTextContent('{"latest":1}')
const closeTrigger = document.querySelector('.absolute.right-4.top-4.cursor-pointer') as HTMLElement
await user.click(screen.getByText('summary'))
expect(screen.getByText('latest text')).toBeInTheDocument()
await user.click(closeTrigger)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should copy the current variable value and reset the copied state after the timeout', async () => {
vi.useFakeTimers()
renderWorkflowComponent(
<ConversationVariableModal
conversationID="conversation-1"
onHide={vi.fn()}
/>,
{
initialStoreState: {
appId: 'app-1',
conversationVariables: [
createConversationVariable(),
],
},
},
)
const copyTrigger = document.querySelector('.flex.items-center.p-1 svg.cursor-pointer') as HTMLElement
act(() => {
fireEvent.click(copyTrigger)
})
expect(mockCopy).toHaveBeenCalledWith('{"draft":true}')
act(() => {
vi.advanceTimersByTime(2000)
})
})
})
describe('UserInput', () => {
it('should hide secret fields outside the expanded panel and persist edits into workflow state', async () => {
const user = userEvent.setup()
const { store } = renderWorkflowFlowComponent(
<UserInput />,
{
nodes: [
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
},
{
type: InputVarType.textInput,
variable: 'internal_note',
label: 'Internal Note',
hide: true,
},
],
},
}),
],
edges: [],
initialStoreState: {
inputs: {
question: 'draft',
},
showDebugAndPreviewPanel: false,
},
},
)
expect(screen.getByLabelText('Question')).toBeInTheDocument()
expect(screen.queryByLabelText('Internal Note')).not.toBeInTheDocument()
await user.clear(screen.getByLabelText('Question'))
await user.type(screen.getByLabelText('Question'), 'updated draft')
expect(store.getState().inputs).toEqual({
question: 'updated draft',
})
})
it('should reveal hidden fields when the debug-and-preview panel is expanded', () => {
renderWorkflowFlowComponent(
<UserInput />,
{
nodes: [
createStartNode({
data: {
variables: [{
type: InputVarType.textInput,
variable: 'internal_note',
label: 'Internal Note',
hide: true,
}],
},
}),
],
edges: [],
initialStoreState: {
inputs: {},
showDebugAndPreviewPanel: true,
},
},
)
expect(screen.getByLabelText('Internal Note')).toBeInTheDocument()
})
})
describe('ChatWrapper', () => {
it('should seed start defaults into workflow inputs and expose restart through the ref handle', async () => {
const chatState = createChatState()
mockUseChat.mockReturnValue(chatState)
const chatRef = createChatWrapperRef()
const { store } = renderWorkflowFlowComponent(
<ChatWrapper
ref={chatRef}
showConversationVariableModal={false}
onConversationModalHide={vi.fn()}
showInputsFieldsPanel
onHide={vi.fn()}
/>,
{
nodes: [
createStartNode({
data: {
variables: [{
type: InputVarType.textInput,
variable: 'name',
label: 'Name',
default: 'Ada',
}],
},
}),
],
edges: [],
initialStoreState: {
inputs: {
custom: 'value',
},
},
},
)
await waitFor(() => {
expect(store.getState().inputs).toEqual({
custom: 'value',
name: 'Ada',
})
})
expect(screen.getByText('workflow.common.previewPlaceholder')).toBeInTheDocument()
act(() => {
chatRef.current?.handleRestart()
})
expect(chatState.handleRestart).toHaveBeenCalledTimes(1)
expect(store.getState().inputs).toEqual({
name: 'Ada',
})
})
it('should hide the side panel while responding and render the conversation modal when requested', async () => {
const onHide = vi.fn()
mockUseChat.mockReturnValue(createChatState({
isResponding: true,
}))
mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([
{
...createConversationVariable({
id: 'var-1',
value: '{"latest":1}',
}),
updated_at: 100,
created_at: 50,
},
]))
renderWorkflowFlowComponent(
<ChatWrapper
ref={createChatWrapperRef()}
showConversationVariableModal
onConversationModalHide={vi.fn()}
showInputsFieldsPanel={false}
onHide={onHide}
/>,
{
nodes: [
createStartNode({
data: {
variables: [],
},
}),
],
edges: [],
initialStoreState: {
appId: 'app-1',
conversationVariables: [
createConversationVariable(),
],
},
},
)
await waitFor(() => {
expect(onHide).toHaveBeenCalledTimes(1)
})
expect(screen.getAllByText('session_state')).toHaveLength(2)
})
it('should forward chat actions, stop subscriptions, and expose paused input state', async () => {
const user = userEvent.setup()
const handleSend = vi.fn()
const handleSwitchSibling = vi.fn()
const handleSubmitHumanInputForm = vi.fn().mockResolvedValue(undefined)
const handleStop = vi.fn()
mockUseChat.mockReturnValue(createChatState({
chatList: [
{
id: 'answer-1',
isAnswer: true,
content: 'first answer',
},
{
id: 'question-1',
isAnswer: false,
content: 'first question',
parentMessageId: 'answer-1',
message_files: [],
},
{
id: 'answer-2',
isAnswer: true,
parentMessageId: 'question-1',
content: 'latest answer',
workflowProcess: {
status: 'paused',
},
},
],
handleSend,
handleSwitchSibling,
handleSubmitHumanInputForm,
handleStop,
}))
const { store } = renderWorkflowFlowComponent(
<ChatWrapper
ref={createChatWrapperRef()}
showConversationVariableModal={false}
onConversationModalHide={vi.fn()}
showInputsFieldsPanel={false}
onHide={vi.fn()}
/>,
{
nodes: [
createStartNode({
data: {
variables: [{
type: InputVarType.textInput,
variable: 'name',
label: 'Name',
default: 'Ada',
}],
},
}),
],
edges: [],
initialStoreState: {
inputs: {
existing: 'value',
},
},
},
)
await waitFor(() => {
expect(store.getState().inputs).toEqual({
existing: 'value',
name: 'Ada',
})
})
expect(screen.getByTestId('chat-input-disabled')).toHaveTextContent('true')
await user.click(screen.getByRole('button', { name: 'send-chat' }))
expect(handleSend).toHaveBeenCalledWith(expect.objectContaining({
query: 'hello',
conversation_id: 'conversation-1',
inputs: {
existing: 'value',
name: 'Ada',
},
parent_message_id: 'answer-2',
}), expect.objectContaining({
onGetSuggestedQuestions: expect.any(Function),
}))
const sendCallbacks = handleSend.mock.calls[0]?.[1] as {
onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void
}
sendCallbacks.onGetSuggestedQuestions('message-1', () => new AbortController())
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-1', expect.any(Function))
await user.click(screen.getByRole('button', { name: 'regenerate-chat' }))
expect(handleSend).toHaveBeenNthCalledWith(2, expect.objectContaining({
query: 'first question',
parent_message_id: 'answer-1',
}), expect.any(Object))
await user.click(screen.getByRole('button', { name: 'switch-sibling' }))
expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({
onGetSuggestedQuestions: expect.any(Function),
}))
const switchCallbacks = handleSwitchSibling.mock.calls[0]?.[1] as {
onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void
}
switchCallbacks.onGetSuggestedQuestions('message-2', () => new AbortController())
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-2', expect.any(Function))
await user.click(screen.getByRole('button', { name: 'submit-human-input' }))
await waitFor(() => {
expect(handleSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { answer: 'ok' })
})
const stopResponding = mockUseChat.mock.calls[0]?.[3] as (taskId: string) => void
stopResponding('task-1')
expect(mockStopChatMessageResponding).toHaveBeenCalledWith('app-1', 'task-1')
const subscription = mockUseSubscription.mock.calls[0]?.[0] as (payload: { type: string }) => void
act(() => {
subscription({ type: EVENT_WORKFLOW_STOP })
})
expect(handleStop).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,267 @@
import type { ReactElement } from 'react'
import type { IToastProps } from '@/app/components/base/toast/context'
import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ToastContext } from '@/app/components/base/toast/context'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import EnvItem from '../env-item'
import VariableModal from '../variable-modal'
import VariableTrigger from '../variable-trigger'
vi.mock('uuid', () => ({
v4: () => 'env-created',
}))
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',
value: '[__HIDDEN__]',
value_type: 'secret',
description: 'secret description',
...overrides,
})
const renderWithProviders = (
ui: ReactElement,
options: {
storeState?: Partial<Shape>
notify?: (props: IToastProps) => void
} = {},
) => {
const store = createWorkflowStore({})
const notify = options.notify ?? vi.fn<(props: IToastProps) => void>()
if (options.storeState)
store.setState(options.storeState)
const result = render(
<ToastContext.Provider value={{ notify, close: vi.fn() }}>
<WorkflowContext.Provider value={store}>
{ui}
</WorkflowContext.Provider>
</ToastContext.Provider>,
)
return {
...result,
store,
notify,
}
}
describe('EnvPanel integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render secret env items and trigger edit and delete actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const env = createEnv()
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={onEdit} onDelete={onDelete} />,
{
storeState: {
envSecrets: {
[env.id]: 'masked-value',
},
},
},
)
expect(screen.getByText('api_key')).toBeInTheDocument()
expect(screen.getByText('Secret')).toBeInTheDocument()
expect(screen.getByText('masked-value')).toBeInTheDocument()
expect(screen.getByText('secret description')).toBeInTheDocument()
const actionWrappers = container.querySelectorAll('.cursor-pointer')
const editIcon = actionWrappers[0]?.querySelector('svg')
const deleteWrapper = actionWrappers[1] as HTMLElement
const deleteIcon = deleteWrapper.querySelector('svg')
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
await user.click(editIcon as SVGElement)
await user.click(deleteIcon as SVGElement)
expect(onEdit).toHaveBeenCalledWith(env)
expect(onDelete).toHaveBeenCalledWith(env)
})
it('should render non-secret env values and clear destructive styling on mouse out', () => {
const env = createEnv({
id: 'env-plain',
name: 'public_value',
value: 'plain-text',
value_type: 'string',
description: '',
})
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={vi.fn()} onDelete={vi.fn()} />,
)
expect(screen.getByText('public_value')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('plain-text')).toBeInTheDocument()
expect(screen.queryByText('secret description')).not.toBeInTheDocument()
const deleteWrapper = container.querySelectorAll('.cursor-pointer')[1] as HTMLElement
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
fireEvent.mouseOut(deleteWrapper)
expect(container.firstElementChild).not.toHaveClass('border-state-destructive-border')
})
it('should create a secret environment variable and normalize spaces in its name', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const onClose = vi.fn()
renderWithProviders(
<VariableModal onClose={onClose} onSave={onSave} />,
{
storeState: {
environmentVariables: [],
},
},
)
await user.click(screen.getByText('Secret'))
await user.type(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), 'my secret')
await user.type(screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), 'top-secret')
await user.type(screen.getByPlaceholderText('workflow.env.modal.descriptionPlaceholder'), 'runtime only')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder')).toHaveValue('my_secret')
expect(onSave).toHaveBeenCalledWith({
id: 'env-created',
name: 'my_secret',
value: 'top-secret',
value_type: 'secret',
description: 'runtime only',
})
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should reject invalid and duplicate variable names', async () => {
const user = userEvent.setup()
const notify = vi.fn()
renderWithProviders(
<VariableModal onClose={vi.fn()} onSave={vi.fn()} />,
{
storeState: {
environmentVariables: [createEnv({ id: 'env-existing', name: 'duplicated', value_type: 'string', value: '1' })],
},
notify,
},
)
fireEvent.change(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), {
target: { value: '1bad' },
})
expect(notify).toHaveBeenCalled()
notify.mockClear()
await user.clear(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'))
await user.type(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), 'duplicated')
await user.type(screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), '42')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'name is existed',
})
})
it('should load existing secret values and convert them to numbers when editing', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
renderWithProviders(
<VariableModal
env={createEnv({
id: 'env-2',
name: 'counter',
value: '[__HIDDEN__]',
description: 'editable',
})}
onClose={vi.fn()}
onSave={onSave}
/>,
{
storeState: {
environmentVariables: [createEnv({ id: 'env-2', name: 'counter' })],
envSecrets: { 'env-2': '123' },
},
},
)
expect(screen.getByDisplayValue('counter')).toBeInTheDocument()
expect(screen.getByDisplayValue('123')).toBeInTheDocument()
await user.click(screen.getByText('Number'))
const valueInput = screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder')
await user.clear(valueInput)
await user.type(valueInput, '9')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
id: 'env-2',
name: 'counter',
value: 9,
value_type: 'number',
description: 'editable',
})
})
it('should open and close the variable trigger modal with the real portal flow', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(false)
return (
<VariableTrigger
open={open}
setOpen={setOpen}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
renderWithProviders(<TriggerHarness />)
const trigger = screen.getByRole('button', { name: 'workflow.env.envPanelButton' })
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.click(trigger)
expect(onClose).toHaveBeenCalledTimes(1)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(2)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
const closeIcon = document.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement
await user.click(closeIcon)
expect(onClose).toHaveBeenCalledTimes(3)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Panel from '../index'
let mockIsChatMode = true
let mockIsWorkflowPage = false
const mockSetShowGlobalVariablePanel = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { setShowGlobalVariablePanel: (visible: boolean) => void }) => unknown) => selector({
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
}),
}))
vi.mock('../../../constants', () => ({
isInWorkflowPage: () => mockIsWorkflowPage,
}))
vi.mock('../../../hooks', () => ({
useIsChatMode: () => mockIsChatMode,
}))
describe('global-variable-panel path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = true
mockIsWorkflowPage = false
})
it('should render chat global variables and close the panel', async () => {
const user = userEvent.setup()
const { container } = render(<Panel />)
expect(screen.getByText('workflow.globalVar.title')).toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === 'sys.conversation_id')).toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === 'sys.dialog_count')).toBeInTheDocument()
expect(screen.queryByText('sys.timestamp')).not.toBeInTheDocument()
await user.click(container.querySelector('.cursor-pointer') as HTMLElement)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
})
it('should render workflow trigger variables for non-chat workflow pages', () => {
mockIsChatMode = false
mockIsWorkflowPage = true
render(<Panel />)
expect(screen.queryByText('sys.conversation_id')).not.toBeInTheDocument()
expect(screen.queryByText('sys.dialog_count')).not.toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === 'sys.timestamp')).toBeInTheDocument()
expect(screen.getByText('workflow.globalVar.fieldsDescription.triggerTimestamp')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,68 @@
import type { Dependency } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PluginDependency from '../index'
import { useStore } from '../store'
vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({
__esModule: true,
default: ({
fromDSLPayload,
onClose,
}: {
fromDSLPayload: Dependency[]
onClose: () => void
}) => (
<div>
<div>{`bundle-size:${fromDSLPayload.length}`}</div>
<button type="button" onClick={onClose}>close-bundle</button>
</div>
),
}))
const createDependency = (): Dependency => ({
type: 'marketplace',
value: {
organization: 'langgenius',
plugin: 'sample-plugin',
version: '1.0.0',
plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0',
},
})
describe('plugin-dependency', () => {
beforeEach(() => {
vi.clearAllMocks()
useStore.setState({
dependencies: [],
})
})
it('should render nothing when there are no dependencies to install', () => {
render(<PluginDependency />)
expect(screen.queryByText(/bundle-size/i)).not.toBeInTheDocument()
})
it('should render the install bundle and clear dependencies when closed', async () => {
const user = userEvent.setup()
useStore.setState({
dependencies: [createDependency()],
})
render(<PluginDependency />)
expect(screen.getByText('bundle-size:1')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close-bundle' }))
expect(useStore.getState().dependencies).toEqual([])
})
it('should update dependencies through the store setter', () => {
const dependency = createDependency()
useStore.getState().setDependencies([dependency])
expect(useStore.getState().dependencies).toEqual([dependency])
})
})

View File

@ -0,0 +1,116 @@
import type { NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import LoopResultPanel from '../loop-result-panel'
const mockTracingPanel = vi.fn()
vi.mock('../tracing-panel', () => ({
default: ({
list,
className,
}: {
list: NodeTracing[]
className?: string
}) => {
mockTracingPanel({ list, className })
return <div data-testid="tracing-panel">{list.length}</div>
},
}))
const createNodeTracing = (id: string): NodeTracing => ({
id,
index: 0,
predecessor_node_id: '',
node_id: `node-${id}`,
node_type: BlockEnum.Code,
title: `Node ${id}`,
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'Tester',
email: 'tester@example.com',
},
finished_at: 0,
execution_metadata: undefined,
})
describe('LoopResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show loop rows, expand tracing details, and handle back and close actions', () => {
const onHide = vi.fn()
const onBack = vi.fn()
const { container } = render(
<LoopResultPanel
list={[
[createNodeTracing('1')],
[createNodeTracing('2'), createNodeTracing('3')],
]}
onHide={onHide}
onBack={onBack}
noWrap
/>,
)
expect(screen.getByText('workflow.singleRun.testRunLoop')).toBeInTheDocument()
const contentPanels = container.querySelectorAll('.transition-all.duration-200')
expect(contentPanels[0]).toHaveClass('max-h-0')
fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
expect(contentPanels[0]).not.toHaveClass('max-h-0')
expect(screen.getAllByTestId('tracing-panel')[0]).toHaveTextContent('1')
expect(mockTracingPanel).toHaveBeenCalledWith({
list: [expect.objectContaining({ id: '1' })],
className: 'bg-background-section-burn',
})
fireEvent.click(screen.getByText('workflow.singleRun.back'))
const closeTrigger = container.querySelector('.ml-2.shrink-0.cursor-pointer.p-1')
if (!closeTrigger)
throw new Error('Expected close trigger to be rendered')
fireEvent.click(closeTrigger)
expect(onBack).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should stop click propagation when rendered inside the overlay wrapper', () => {
const parentClick = vi.fn()
const { container } = render(
<div onClick={parentClick}>
<LoopResultPanel
list={[[createNodeTracing('1')]]}
onHide={vi.fn()}
onBack={vi.fn()}
/>
</div>,
)
const overlay = container.querySelector('.absolute.inset-0')
if (!overlay)
throw new Error('Expected overlay wrapper to be rendered')
fireEvent.click(overlay)
expect(parentClick).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,101 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentLogNav from '../agent-log-nav'
import AgentLogNavMore from '../agent-log-nav-more'
import AgentResultPanel from '../agent-result-panel'
vi.mock('../agent-log-item', () => ({
default: ({ item, onShowAgentOrToolLog }: any) => (
<button type="button" onClick={() => onShowAgentOrToolLog(item)}>
item-{item.label}
</button>
),
}))
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: {},
...overrides,
})
describe('agent-log leaf components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The navigation and result views should expose stack navigation and nested agent log entries.
describe('Navigation and Results', () => {
it('should navigate back, open intermediate entries, and show the tail label', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const stack = [
createLogItem({ message_id: 'root', label: 'Strategy' }),
createLogItem({ message_id: 'mid', label: 'Tool A' }),
createLogItem({ message_id: 'tail', label: 'Tool B' }),
]
render(
<AgentLogNav
agentOrToolLogItemStack={stack}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button', { name: /^AGENT$/i }))
await user.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ }))
await user.click(screen.getAllByRole('button')[2]!)
await user.click(screen.getByText('Tool A'))
expect(onShowAgentOrToolLog.mock.calls[0]).toHaveLength(0)
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(2, stack[0])
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(3, stack[1])
expect(screen.getByText('Tool B')).toBeInTheDocument()
})
it('should render the more menu options as shortcuts to nested logs', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const option = createLogItem({ message_id: 'mid', label: 'Intermediate Tool' })
render(
<AgentLogNavMore
options={[option]}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('Intermediate Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(option)
})
it('should render result items and the circular invocation warning', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const top = createLogItem({ message_id: 'top', label: 'Top', hasCircle: true })
const child = createLogItem({ message_id: 'child', label: 'Child Tool' })
render(
<AgentResultPanel
agentOrToolLogItemStack={[top]}
agentOrToolLogListMap={{ top: [child] }}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
expect(screen.getByText('runLog.circularInvocationTip')).toBeInTheDocument()
await user.click(screen.getByText('item-Child Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(child)
})
})
})

View File

@ -28,7 +28,7 @@ const AgentLogNavMore = ({
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className="h-6 w-6"
variant="ghost-accent"

View File

@ -0,0 +1,70 @@
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import IterationResultPanel from '../iteration-result-panel'
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: NodeTracing[] }) => (
<div data-testid="tracing-panel">
{list.map(item => (
<div key={`${item.node_id}-${item.execution_metadata?.iteration_index}`}>{item.node_id}</div>
))}
</div>
),
}))
const createTracing = (
nodeId: string,
status: NodeRunningStatus,
iterationIndex: number,
parallelModeRunId?: string,
): NodeTracing => {
return {
node_id: nodeId,
status,
execution_metadata: {
iteration_index: iterationIndex,
parallel_mode_run_id: parallelModeRunId,
},
} as NodeTracing
}
describe('IterationResultPanel integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render failed, running, and completed iterations and toggle tracing details', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
const list: NodeTracing[][] = [
[createTracing('failed-node', NodeRunningStatus.Failed, 0, 'iter-1')],
[createTracing('running-node', NodeRunningStatus.Running, 1, 'iter-2')],
[createTracing('done-node', NodeRunningStatus.Succeeded, 2, 'iter-3')],
]
const durationMap: IterationDurationMap = {
'iter-3': 0.001,
}
const { container } = render(
<IterationResultPanel
list={list}
onBack={onBack}
iterDurationMap={durationMap}
/>,
)
expect(screen.getByText('0.01s')).toBeInTheDocument()
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalledTimes(1)
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
expect(container.querySelectorAll('.opacity-100')).toHaveLength(1)
expect(screen.getByText('done-node')).toBeInTheDocument()
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
expect(container.querySelectorAll('.opacity-100')).toHaveLength(0)
})
})

View File

@ -0,0 +1,75 @@
/* eslint-disable ts/no-explicit-any */
import type { NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../../types'
import RetryResultPanel from '../retry-result-panel'
vi.mock('../../tracing-panel', () => ({
default: ({ list }: any) => (
<div>
{list.map((item: any) => (
<div key={item.id}>{item.title}</div>
))}
</div>
),
}))
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Code',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0.1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 2,
...overrides,
})
describe('RetryResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The retry result panel should expose a back action and relabel each retry attempt in the tracing list.
describe('Rendering', () => {
it('should render retry titles and call onBack from the back header', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
render(
<RetryResultPanel
list={[createTrace({ id: 'retry-1' }), createTrace({ id: 'retry-2' })]}
onBack={onBack}
/>,
)
expect(screen.getByText('workflow.nodes.common.retry.retry 1')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.retry.retry 2')).toBeInTheDocument()
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,138 @@
import { render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../types'
import SimpleNode from '../index'
let mockNodesReadOnly = false
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
__esModule: true,
default: ({ type }: { type: BlockEnum }) => <div>{`block-icon:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-control', () => ({
__esModule: true,
default: ({ id }: { id: string }) => <div>{`node-control:${id}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeTargetHandle: ({ handleId }: { handleId: string }) => <div>{`node-handle:${handleId}`}</div>,
}))
const createData = (overrides: Record<string, unknown> = {}) => ({
title: 'Answer',
desc: '',
type: BlockEnum.Answer,
...overrides,
})
describe('simple-node', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
})
it('should render the block shell, target handle, and node control by default', () => {
render(
<SimpleNode
id="simple-node"
data={createData()}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('block-icon:answer')).toBeInTheDocument()
expect(screen.getByText('node-handle:target')).toBeInTheDocument()
expect(screen.getByText('node-control:simple-node')).toBeInTheDocument()
})
it('should show the running state border and spinner', () => {
const { container } = render(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Running,
})}
/>,
)
expect(container.querySelector('.text-text-accent')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-accent-solid')
expect(screen.queryByText('node-control:simple-node')).not.toBeInTheDocument()
})
it('should show success, failed, and exception status indicators', () => {
const { container, rerender } = render(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Succeeded,
})}
/>,
)
expect(container.querySelector('.text-text-success')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-success-solid')
rerender(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Failed,
})}
/>,
)
expect(container.querySelector('.text-text-destructive')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-destructive-solid')
rerender(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Exception,
})}
/>,
)
expect(container.querySelector('.text-text-warning-secondary')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-warning-solid')
})
it('should hide handles and controls for candidate or read-only nodes and show selected waiting styles', () => {
mockNodesReadOnly = true
const { container } = render(
<SimpleNode
id="simple-node"
data={createData({
selected: true,
_waitingRun: true,
_isCandidate: true,
})}
/>,
)
expect(screen.queryByText('node-handle:target')).not.toBeInTheDocument()
expect(screen.queryByText('node-control:simple-node')).not.toBeInTheDocument()
expect(container.querySelector('.border-components-option-card-option-selected-border')).not.toBeNull()
expect(container.querySelector('.opacity-70')).not.toBeNull()
})
it('should show a spinner when a single run is still running', () => {
const { container } = render(
<SimpleNode
id="simple-node"
data={createData({
_singleRunningStatus: NodeRunningStatus.Running,
})}
/>,
)
expect(container.querySelector('.animate-spin')).not.toBeNull()
})
})

View File

@ -4695,9 +4695,6 @@
"app/components/header/account-setting/data-source-page-new/configure.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts": {
@ -4724,9 +4721,6 @@
"app/components/header/account-setting/data-source-page-new/operator.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/header/account-setting/data-source-page-new/types.ts": {
@ -4753,28 +4747,10 @@
}
},
"app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 3
}
},
"app/components/header/account-setting/members-page/invite-modal/role-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/members-page/invited-modal/index.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/members-page/operation/index.tsx": {
"no-restricted-imports": {
"count": 2
@ -4828,9 +4804,6 @@
"app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.tsx": {
@ -4842,9 +4815,6 @@
"no-restricted-imports": {
"count": 3
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
@ -4862,9 +4832,6 @@
"app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 4
}
},
"app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts": {
@ -8967,7 +8934,7 @@
},
"app/components/workflow/panel/chat-record/user-input.tsx": {
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"app/components/workflow/panel/chat-variable-panel/components/array-bool-list.tsx": {

View File

@ -66,7 +66,6 @@
"@formatjs/intl-localematcher": "0.8.2",
"@headlessui/react": "2.2.9",
"@heroicons/react": "2.2.0",
"@hono/node-server": "1.19.11",
"@lexical/code": "0.42.0",
"@lexical/link": "0.42.0",
"@lexical/list": "0.42.0",
@ -108,7 +107,6 @@
"es-toolkit": "1.45.1",
"fast-deep-equal": "3.1.3",
"foxact": "0.3.0",
"hono": "4.12.8",
"html-entities": "2.6.0",
"html-to-image": "1.11.13",
"i18next": "25.10.4",
@ -170,6 +168,7 @@
"@chromatic-com/storybook": "5.0.2",
"@egoist/tailwindcss-icons": "1.9.2",
"@eslint-react/eslint-plugin": "3.0.0",
"@hono/node-server": "1.19.11",
"@iconify-json/heroicons": "1.2.3",
"@iconify-json/ri": "1.2.10",
"@mdx-js/loader": "3.1.1",
@ -224,6 +223,7 @@
"eslint-plugin-react-refresh": "0.5.2",
"eslint-plugin-sonarjs": "4.0.2",
"eslint-plugin-storybook": "10.3.1",
"hono": "4.12.8",
"husky": "9.1.7",
"iconify-import-svg": "0.1.2",
"jsdom": "29.0.1",

View File

@ -85,9 +85,6 @@ importers:
'@heroicons/react':
specifier: 2.2.0
version: 2.2.0(react@19.2.4)
'@hono/node-server':
specifier: 1.19.11
version: 1.19.11(hono@4.12.8)
'@lexical/code':
specifier: npm:lexical-code-no-prism@0.41.0
version: lexical-code-no-prism@0.41.0(@lexical/utils@0.42.0)(lexical@0.42.0)
@ -211,9 +208,6 @@ importers:
foxact:
specifier: 0.3.0
version: 0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
hono:
specifier: 4.12.8
version: 4.12.8
html-entities:
specifier: 2.6.0
version: 2.6.0
@ -392,6 +386,9 @@ importers:
'@eslint-react/eslint-plugin':
specifier: 3.0.0
version: 3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)
'@hono/node-server':
specifier: 1.19.11
version: 1.19.11(hono@4.12.8)
'@iconify-json/heroicons':
specifier: 1.2.3
version: 1.2.3
@ -554,6 +551,9 @@ importers:
eslint-plugin-storybook:
specifier: 10.3.1
version: 10.3.1(eslint@10.1.0(jiti@1.21.7))(storybook@10.3.1(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
hono:
specifier: 4.12.8
version: 4.12.8
husky:
specifier: 9.1.7
version: 9.1.7