diff --git a/api/models/execution_extra_content.py b/api/models/execution_extra_content.py index d0bd34efec..b2d09a7732 100644 --- a/api/models/execution_extra_content.py +++ b/api/models/execution_extra_content.py @@ -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", diff --git a/api/pyproject.toml b/api/pyproject.toml index a16e1ed934..d4d0ebcf7f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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", diff --git a/api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py deleted file mode 100644 index c9058626d1..0000000000 --- a/api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py +++ /dev/null @@ -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 diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py new file mode 100644 index 0000000000..ed998c9ed0 --- /dev/null +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -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 == [] diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py deleted file mode 100644 index 8daf91c538..0000000000 --- a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ /dev/null @@ -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()) diff --git a/api/uv.lock b/api/uv.lock index dbda8941f4..30c5b851bc 100644 --- a/api/uv.lock +++ b/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]] diff --git a/docker/dify-env-sync.py b/docker/dify-env-sync.py new file mode 100755 index 0000000000..d7c762748c --- /dev/null +++ b/docker/dify-env-sync.py @@ -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 ``/env-backup/`` with the filename + ``.env.backup_``. + + 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() diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx index 97934f36e1..8d055606b8 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({ {value} {items.length} {items.map((item: MockSelectItem) => ( - ))} diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx index efe6c46dcc..5f1492f14a 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx @@ -78,6 +78,7 @@ const ApiBasedExtensionModal: FC = ({
diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index 38acb73154..62052aece6 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -69,7 +69,7 @@ const ApiBasedExtensionSelector: FC = ({ ) } - +
diff --git a/web/app/components/header/account-setting/data-source-page-new/configure.tsx b/web/app/components/header/account-setting/data-source-page-new/configure.tsx index a3dba783e1..484338d333 100644 --- a/web/app/components/header/account-setting/data-source-page-new/configure.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/configure.tsx @@ -84,7 +84,7 @@ const Configure = ({ {t('dataSource.configure', { ns: 'common' })} - +
{ !!canOAuth && ( @@ -104,7 +104,7 @@ const Configure = ({ } { !!canApiKey && !!canOAuth && ( -
+
OR
diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index 14bdee4fd0..c5b2a948de 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -39,7 +39,7 @@ const Operator = ({ text: (
-
{t('auth.setDefault', { ns: 'plugin' })}
+
{t('auth.setDefault', { ns: 'plugin' })}
), }, @@ -51,7 +51,7 @@ const Operator = ({ text: (
-
{t('operation.rename', { ns: 'common' })}
+
{t('operation.rename', { ns: 'common' })}
), }, @@ -66,7 +66,7 @@ const Operator = ({ text: (
-
{t('operation.edit', { ns: 'common' })}
+
{t('operation.edit', { ns: 'common' })}
), }, @@ -81,7 +81,7 @@ const Operator = ({ text: (
-
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
+
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
), }, @@ -98,7 +98,7 @@ const Operator = ({ text: (
-
+
{t('operation.remove', { ns: 'common' })}
@@ -122,7 +122,7 @@ const Operator = ({ items={items} secondItems={secondItems} onSelect={handleSelect} - popupClassName="z-[61]" + popupClassName="z-[1002]" triggerProps={{ size: 'l', }} diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index d2aeca1b6c..7de1fbeccb 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -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( - - - , + , ) + 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[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') diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.module.css b/web/app/components/header/account-setting/members-page/invite-modal/index.module.css deleted file mode 100644 index fbaa1187bd..0000000000 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.module.css +++ /dev/null @@ -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; -} diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 8e4e47e0b8..9b4e9fccdc 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -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([]) - 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 ( -
- -
-
{t('members.inviteTeamMember', { ns: 'common' })}
-
+ { + if (!open) + onCancel() + }} + > + + +
+ + {t('members.inviteTeamMember', { ns: 'common' })} +
{t('members.inviteTeamMemberTip', { ns: 'common' })}
{!isEmailSetup && ( @@ -152,8 +155,8 @@ const InviteModal = ({ {t('members.sendInvite', { ns: 'common' })}
- -
+ + ) } diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx index e258884b0f..6383b203d9 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx @@ -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 ( - -
- setOpen(v => !v)} - className="block" - > + +
{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}
+
+ + +
{ + onChange('normal') + setOpen(false) + }} > -
{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}
-
-
- - -
-
-
{ - onChange('normal') - setOpen(false) - }} - > -
-
{t('members.normal', { ns: 'common' })}
-
{t('members.normalTip', { ns: 'common' })}
- {value === 'normal' && ( -
- )} -
-
-
{ - onChange('editor') - setOpen(false) - }} - > -
-
{t('members.editor', { ns: 'common' })}
-
{t('members.editorTip', { ns: 'common' })}
- {value === 'editor' && ( -
- )} -
-
-
{ - onChange('admin') - setOpen(false) - }} - > -
-
{t('members.admin', { ns: 'common' })}
-
{t('members.adminTip', { ns: 'common' })}
- {value === 'admin' && ( -
- )} -
-
- {datasetOperatorEnabled && ( +
+
{t('members.normal', { ns: 'common' })}
+
{t('members.normalTip', { ns: 'common' })}
+ {value === 'normal' && (
{ - onChange('dataset_operator') - setOpen(false) - }} - > -
-
{t('members.datasetOperator', { ns: 'common' })}
-
{t('members.datasetOperatorTip', { ns: 'common' })}
- {value === 'dataset_operator' && ( -
- )} -
-
+ 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" + /> )}
- -
- +
{ + onChange('editor') + setOpen(false) + }} + > +
+
{t('members.editor', { ns: 'common' })}
+
{t('members.editorTip', { ns: 'common' })}
+ {value === 'editor' && ( +
+ )} +
+
+
{ + onChange('admin') + setOpen(false) + }} + > +
+
{t('members.admin', { ns: 'common' })}
+
{t('members.adminTip', { ns: 'common' })}
+ {value === 'admin' && ( +
+ )} +
+
+ {datasetOperatorEnabled && ( +
{ + onChange('dataset_operator') + setOpen(false) + }} + > +
+
{t('members.datasetOperator', { ns: 'common' })}
+
{t('members.datasetOperatorTip', { ns: 'common' })}
+ {value === 'dataset_operator' && ( +
+ )} +
+
+ )} +
+ + ) } diff --git a/web/app/components/header/account-setting/members-page/invited-modal/index.tsx b/web/app/components/header/account-setting/members-page/invited-modal/index.tsx index 389db4a42d..dbabb384a2 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/index.tsx @@ -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 @@ -29,8 +24,18 @@ const InvitedModal = ({ const failedInvitationResults = useMemo(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults]) return ( -
- + { + if (!open) + onCancel() + }} + > + +
- +
-
-
{t('members.invitationSent', { ns: 'common' })}
+ {t('members.invitationSent', { ns: 'common' })} {!IS_CE_EDITION && (
{t('members.invitationSentTip', { ns: 'common' })}
)} @@ -54,7 +58,7 @@ const InvitedModal = ({ !!successInvitationResults.length && ( <> -
{t('members.invitationLink', { ns: 'common' })}
+
{t('members.invitationLink', { ns: 'common' })}
{successInvitationResults.map(item => )} @@ -64,18 +68,23 @@ const InvitedModal = ({ !!failedInvitationResults.length && ( <> -
{t('members.failedInvitationEmails', { ns: 'common' })}
+
{t('members.failedInvitationEmails', { ns: 'common' })}
{ failedInvitationResults.map(item => (
- -
- {item.email} - -
+ + + {item.email} +
+
+ )} + /> + + {item.message} +
), @@ -97,8 +106,8 @@ const InvitedModal = ({ {t('members.ok', { ns: 'common' })}
- -
+
+
) } diff --git a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx index 8f55660fd8..0c5874c4dc 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx @@ -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 = ({
- -
{value.url}
+ + {value.url}
} + /> + + {isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })} +
- -
-
-
-
+ + +
+
+
+ )} + /> + + {isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })} +
diff --git a/web/app/components/header/account-setting/members-page/operation/index.tsx b/web/app/components/header/account-setting/members-page/operation/index.tsx index 35c4676d5f..e2b14b9078 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.tsx @@ -102,7 +102,7 @@ const Operation = ({
- +
{ diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index 099a146866..6a2af9ffdb 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -141,6 +141,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
= ({
- +
{renderTrigger(open)} - +
{ @@ -136,7 +136,7 @@ const AddCustomModel = ({ modelName={model.model} />
{model.model} @@ -148,7 +148,7 @@ const AddCustomModel = ({ { !notAllowCustomCredential && (
{ handleOpenModalForAddNewCustomModel() setOpen(false) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx index e2f859b09d..15101a6542 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx @@ -164,7 +164,7 @@ const Authorized = ({ > {renderTrigger(mergedIsOpen)} - +
{ popupTitle && ( -
+
{popupTitle}
) @@ -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" > {t('modelProvider.auth.addModelCredential', { ns: 'common' })} diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx index 52513e7aeb..dd1d8e6eb9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx @@ -53,14 +53,14 @@ const CredentialSelector = ({ triggerPopupSameWidth > !disabled && setOpen(v => !v)}> -
+
{ selectedCredential && (
{ !selectedCredential.addNewCredential && } -
{selectedCredential.credential_name}
+
{selectedCredential.credential_name}
{ selectedCredential.from_enterprise && ( Enterprise @@ -71,13 +71,13 @@ const CredentialSelector = ({ } { !selectedCredential && ( -
{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}
+
{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}
) }
- +
{ @@ -98,7 +98,7 @@ const CredentialSelector = ({ { !notAllowAddNewCredential && (
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx index 13fb974728..d0f7ac7e53 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx @@ -244,6 +244,7 @@ const ModelLoadBalancingModal = ({ diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx index 6ee8a7a924..3dce8375b3 100644 --- a/web/app/components/header/nav/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/__tests__/index.spec.tsx @@ -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 ( - + {typeof children === 'function' ? children({ open }) : children} - + ) } 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 & { href: string, children?: React.ReactNode }) => ( + { + event.preventDefault() + onClick?.(event) + }} + {...props} + > + {children} + + ), +})) + describe('Nav Component', () => { const mockSetAppDetail = vi.fn() const mockOnCreate = vi.fn() diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx new file mode 100644 index 0000000000..47ad2fad02 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -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() + 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 + onHide: () => void + }) => ( +
+ + +
+ ), +})) + +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 + onClose: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('@/utils/var', async (importOriginal) => { + const actual = await importOriginal() + 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 => ({ + 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 => ({ + provider_name: 'provider-a', + tool_name: 'tool-a', + tool_label: 'Tool A', + ...overrides, +}) + +const createPlugin = (overrides: Partial = {}): 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> = {}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + open-picker} + isShow={false} + onShowChange={vi.fn()} + onSelect={vi.fn()} + onSelectMultiple={vi.fn()} + selectedTools={[createToolValue()]} + {...props} + /> + , + ) +} + +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) + 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) + mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType) + mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType) + mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType) + mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType) + mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools) + mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools) + mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools) + mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools) + mockUseFeaturedToolsRecommendations.mockReturnValue({ + plugins: [], + isLoading: false, + } as ReturnType) + 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) + + 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) + + 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 }) + }) +}) diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx new file mode 100644 index 0000000000..c3c3eaf911 --- /dev/null +++ b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx @@ -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
{`dataset-count:${datasetCount}`}
+} + +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( + + + , + ) + + 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( + + + , + ) + + await waitFor(() => { + expect(mockFetchDatasets).toHaveBeenCalledWith({ + url: '/datasets', + params: { + page: 1, + ids: ['dataset-1', 'dataset-2'], + }, + }) + expect(screen.getByText('dataset-count:2')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx new file mode 100644 index 0000000000..dc00d61301 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -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: () =>
editing-title
, +})) + +vi.mock('../scroll-to-selected-node-button', () => ({ + default: () =>
scroll-button
, +})) + +vi.mock('../env-button', () => ({ + default: ({ disabled }: { disabled: boolean }) =>
{`${disabled}`}
, +})) + +vi.mock('../global-variable-button', () => ({ + default: ({ disabled }: { disabled: boolean }) =>
{`${disabled}`}
, +})) + +vi.mock('../run-and-history', () => ({ + default: (props: object) => { + mockRunAndHistory(props) + return
+ }, +})) + +vi.mock('../version-history-button', () => ({ + default: ({ onClick }: { onClick: () => void }) => ( + + ), +})) + +vi.mock('../restoring-title', () => ({ + default: () =>
restoring-title
, +})) + +vi.mock('../running-title', () => ({ + default: () =>
running-title
, +})) + +vi.mock('../view-history', () => ({ + default: (props: { withText?: boolean }) => { + mockViewHistory(props) + return
{props.withText ? 'with-text' : 'icon-only'}
+ }, +})) + +const createSelectedNode = (selected = true) => ({ + id: 'node-selected', + data: { + selected, + }, +}) + +const createBackupDraft = (): NonNullable => ({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + environmentVariables: [], +}) + +const createCurrentVersion = (): NonNullable => ({ + 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( + left-slot
, + middle:
middle-slot
, + chatVariableTrigger:
chat-trigger
, + }} + />, + { + 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( + , + { + 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( + , + { + 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( + , + { + 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, + })) + }) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/index.spec.tsx b/web/app/components/workflow/header/__tests__/index.spec.tsx new file mode 100644 index 0000000000..70d6fae88c --- /dev/null +++ b/web/app/components/workflow/header/__tests__/index.spec.tsx @@ -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: (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> }>, + ) => { + const DynamicComponent = (props: Record) => { + const [Loaded, setLoaded] = ReactModule.useState> | null>(null) + + ReactModule.useEffect(() => { + let mounted = true + loader().then((mod) => { + if (mounted) + setLoaded(() => mod.default) + }) + return () => { + mounted = false + } + }, []) + + return Loaded ? : null + } + + return DynamicComponent + }, + } +}) + +vi.mock('../header-in-normal', () => ({ + default: () =>
normal-layout
, +})) + +vi.mock('../header-in-view-history', () => ({ + default: () =>
history-layout
, +})) + +vi.mock('../header-in-restoring', () => ({ + default: () =>
restoring-layout
, +})) + +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(
) + + 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(
) + + 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() + }) +}) diff --git a/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx b/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx new file mode 100644 index 0000000000..bbd9636e5e --- /dev/null +++ b/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx @@ -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('../store') + return { + ...actual, + createHooksStore: vi.fn(() => mockStore), + } +}) + +const Consumer = () => { + const store = useContext(HooksStoreContext) + return
{store ? 'has-hooks-store' : 'missing-hooks-store'}
+} + +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( + + + , + ) + + 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( + + + , + ) + + await waitFor(() => { + expect(mockRefreshAll).toHaveBeenCalledWith({ + handleRun, + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/__tests__/index.spec.tsx new file mode 100644 index 0000000000..41eb853a99 --- /dev/null +++ b/web/app/components/workflow/nodes/__tests__/index.spec.tsx @@ -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]: () =>
start-node-component
, + }, + PanelComponentMap: { + [BlockEnum.Start]: () =>
start-panel-component
, + }, +})) + +vi.mock('../_base/node', () => ({ + __esModule: true, + default: ({ + id, + data, + children, + }: { + id: string + data: { type: BlockEnum } + children: ReactElement + }) => ( +
+
{`base-node:${id}:${data.type}`}
+ {children} +
+ ), +})) + +vi.mock('../_base/components/workflow-panel', () => ({ + __esModule: true, + default: ({ + id, + data, + children, + }: { + id: string + data: { type: BlockEnum } + children: ReactElement + }) => ( +
+
{`base-panel:${id}:${data.type}`}
+ {children} +
+ ), +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx new file mode 100644 index 0000000000..ffe1e80bb0 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx @@ -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 => ({ + 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) + mockUseFileSizeLimit.mockReturnValue({ + imgSizeLimit: 10 * 1024 * 1024, + docSizeLimit: 20 * 1024 * 1024, + audioSizeLimit: 30 * 1024 * 1024, + videoSizeLimit: 40 * 1024 * 1024, + maxFileUploadLimit: 10, + } as ReturnType) + }) + + describe('FileTypeItem', () => { + it('should render built-in file types and toggle the selected type on click', () => { + const onToggle = vi.fn() + + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.file.document.name')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_types: [], + })) + + rerender( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.file.custom.name')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.custom], + })) + + rerender( + , + ) + + 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( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.both')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + })) + + rerender( + , + ) + + 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( + , + ) + + expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9521f9b307 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx @@ -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() + return { + ...actual, + useDocLink: () => mockDocLink, + } +}) + +vi.mock('../hooks', () => ({ + useDefaultValue: vi.fn(), + useErrorHandle: vi.fn(), +})) + +vi.mock('../../node-handle', () => ({ + NodeSourceHandle: ({ handleId }: { handleId: string }) =>
, +})) + +const mockUseDefaultValue = vi.mocked(useDefaultValue) +const mockUseErrorHandle = vi.mocked(useErrorHandle) +const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly + +const baseData = (overrides: Partial = {}): CommonNodeType => ({ + title: 'Code', + desc: '', + type: 'code' as CommonNodeType['type'], + ...overrides, +}) + +const ErrorHandleNodeHarness = ({ id, data }: NodeProps) => ( + +) + +const renderErrorHandleNode = (data: CommonNodeType) => + renderWorkflowFlowComponent(
, { + 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() + + 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( + , + ) + + 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( + , + ) + + 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() + + expect(container).toBeEmptyDOMElement() + + rerender() + expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument() + + rerender() + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument() + }) + + it('should render the default-value node badge', () => { + renderWorkflowFlowComponent( + , + { + 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) + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx index a6d6d0bf6c..38736c573d 100644 --- a/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx @@ -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() + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeNull() + }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx index 680965eb06..071e7f011b 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx @@ -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( +
+ Box content + Group content +
, + ) + + 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( +
+ Inside box group + + Group field body + +
, + ) + + 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( { expect(screen.getByText('Body content')).toBeInTheDocument() }) - it('should render FieldTitle from the barrel export', () => { - render() + it('should collapse and expand Field children when supportCollapse is enabled', async () => { + const user = userEvent.setup() + render( + +
Extra details
+
, + ) - 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() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx new file mode 100644 index 0000000000..1c68990d34 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx @@ -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 ( +
+
{editable ? 'editable' : 'readonly'}
+
{value || 'empty'}
+ +
+ ) + }, +})) + +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( + , + ) + + 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( + , + ) + + 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') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx new file mode 100644 index 0000000000..03e67f68de --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx @@ -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( +
+ +
, + ) + + 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() + + 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) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx new file mode 100644 index 0000000000..3e02aba077 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx @@ -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) => ( +
+
{trigger()}
+
{`available:${(availableBlocksTypes || []).join(',')}`}
+
{`show-start:${String(showStartTab)}`}
+
{`ignore:${(ignoreNodeIds || []).join(',')}`}
+
{`force-start:${String(forceEnableStartTab)}`}
+
{`allow-start:${String(allowUserInputSelection)}`}
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + 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) + 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) + mockUseNodesInteractions.mockReturnValue({ + handleNodeChange, + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) + mockUseNodesSyncDraft.mockReturnValue({ + doSyncWorkflowDraft: vi.fn(), + handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + } as ReturnType) + 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( + , + ) + + 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) + mockUseIsChatMode.mockReturnValueOnce(true) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValueOnce([] as any) + + const { rerender } = render( + , + ) + + 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) + 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( + , + ) + + 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( + , + { + 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( + , + { + nodes: [], + edges: [], + }, + ) + + expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') + + mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) + mockUseNodeMetaData.mockReturnValueOnce({ + isTypeFixed: true, + isSingleton: true, + isUndeletable: true, + description: 'Read only node', + author: 'Dify', + } as ReturnType) + + rerender( + , + ) + + expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx new file mode 100644 index 0000000000..5fbab5e497 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx @@ -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() + + 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( + + + , + ) + + 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( + + + , + ) + + expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument() + expect(screen.getByTitle('readonly content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx new file mode 100644 index 0000000000..e1a7ae4a4b --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx @@ -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
{vars.length}
+ }, +})) + +const createOutputVar = (overrides: Partial = {}): 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( + , + ) + + 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( + , + ) + + expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1') + expect(mockVarReferenceVars).toHaveBeenCalledWith({ + vars: [createOutputVar()], + onChange, + itemWidth: 280, + isSupportFileVar: true, + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx index cb44e93427..d75e6b6036 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx @@ -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( + , + ) + + 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( + suffix} + />, + ) + + 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( +
+ + +
, + ) + + expect(container.querySelectorAll('svg').length).toBeGreaterThan(0) + }) + + it('should render the base variable name with shortened path and title', () => { + render( + , + ) + + expect(screen.getByText('answer')).toHaveAttribute('title', 'answer') + }) + + it('should render the base node label only when node type exists', () => { + const { container, rerender } = render() + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(screen.getByText('Code Node')).toBeInTheDocument() + }) + + it('should render the base label with variable type and right slot', () => { + render( + slot} + />, + ) + + expect(screen.getByText('Source Node')).toBeInTheDocument() + expect(screen.getByText('query')).toBeInTheDocument() + expect(screen.getByText('String')).toBeInTheDocument() + expect(screen.getByText('slot')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx new file mode 100644 index 0000000000..a7913ae0aa --- /dev/null +++ b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx @@ -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 | undefined = [] +let mockCustomTools: Array | undefined = [] +let mockWorkflowTools: Array | undefined = [] +let mockMcpTools: Array | undefined = [] +let mockMarketplaceIcon: string | Record | 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) => ( +
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}
+ ), +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: any) =>
{`indicator:${color}`}
, +})) + +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) =>
{`app-icon:${background}:${icon}`}
, +})) + +vi.mock('@/app/components/base/icons/src/vender/other', () => ({ + Group: () =>
group-icon
, +})) + +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) =>
{label}
{children}
, + GroupLabel: ({ className, children }: any) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({ + SettingItem: ({ label, status, tooltip, children }: any) =>
{label}:{status}:{tooltip}:{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ + default: ({ title, children }: any) =>
{title}
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({ + AgentStrategy: ({ onStrategyChange }: any) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({ + MCPToolAvailabilityProvider: ({ children }: any) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ + default: ({ onChange }: any) => , +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ + default: ({ children }: any) =>
{children}
, + VarItem: ({ name, type, description }: any) =>
{`${name}:${type}:${description}`}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ + default: () =>
split
, +})) + +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 => ({ + 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 => ({ + 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() + + expect(container).toHaveTextContent('no-model:0') + expect(screen.getByText('indicator:red')).toBeInTheDocument() + + rerender() + expect(container).toHaveTextContent('openai/gpt-4o:1') + expect(screen.queryByText('indicator:red')).not.toBeInTheDocument() + + rerender() + 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() + + 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() + expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument() + expect(screen.getByText('indicator:yellow')).toBeInTheDocument() + + mockBuiltInTools = undefined + secondRender.rerender() + expect(screen.getByText('group-icon')).toBeInTheDocument() + + mockBuiltInTools = [] + secondRender.rerender() + 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( + , + ) + + 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( + , + ) + + 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', + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx new file mode 100644 index 0000000000..0b814b8b25 --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx @@ -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) =>
{title}
{operations}
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({ + default: ({ children }: any) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => ( +
+
{Array.isArray(value) ? value.join('.') : String(value ?? '')}
+ {valueTypePlaceHolder &&
{`type:${valueTypePlaceHolder}`}
} + {popupFor === 'toAssigned' && ( +
{`filter:${String(filterVar?.({ nodeId: 'node-1', variable: 'count', type: VarType.string }))}:${String(filterVar?.({ nodeId: 'node-2', variable: 'other', type: VarType.string }))}`}
+ )} + +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value, onChange }: any) => ( +