mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into 3-23-lazy-load
This commit is contained in:
commit
d44f1d63b1
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 == []
|
||||
|
|
@ -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())
|
||||
266
api/uv.lock
266
api/uv.lock
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]" />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
|||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="!w-[420px] !p-6"
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' })}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue