diff --git a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py index 7b53f47419..dc3b70140b 100644 --- a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py +++ b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py @@ -58,11 +58,39 @@ class OceanBaseVector(BaseVector): password=self._config.password, db_name=self._config.database, ) + self._fields: list[str] = [] # List of fields in the collection + if self._client.check_table_exists(collection_name): + self._load_collection_fields() self._hybrid_search_enabled = self._check_hybrid_search_support() # Check if hybrid search is supported def get_type(self) -> str: return VectorType.OCEANBASE + def _load_collection_fields(self): + """ + Load collection fields from the database table. + This method populates the _fields list with column names from the table. + """ + try: + if self._collection_name in self._client.metadata_obj.tables: + table = self._client.metadata_obj.tables[self._collection_name] + # Store all column names except 'id' (primary key) + self._fields = [column.name for column in table.columns if column.name != "id"] + logger.debug("Loaded fields for collection '%s': %s", self._collection_name, self._fields) + else: + logger.warning("Collection '%s' not found in metadata", self._collection_name) + except Exception as e: + logger.warning("Failed to load collection fields for '%s': %s", self._collection_name, str(e)) + + def field_exists(self, field: str) -> bool: + """ + Check if a field exists in the collection. + + :param field: Field name to check + :return: True if field exists, False otherwise + """ + return field in self._fields + def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): self._vec_dim = len(embeddings[0]) self._create_collection() @@ -151,6 +179,7 @@ class OceanBaseVector(BaseVector): logger.debug("DEBUG: Hybrid search is NOT enabled for '%s'", self._collection_name) self._client.refresh_metadata([self._collection_name]) + self._load_collection_fields() redis_client.set(collection_exist_cache_key, 1, ex=3600) def _check_hybrid_search_support(self) -> bool: @@ -177,42 +206,134 @@ class OceanBaseVector(BaseVector): def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): ids = self._get_uuids(documents) for id, doc, emb in zip(ids, documents, embeddings): - self._client.insert( - table_name=self._collection_name, - data={ - "id": id, - "vector": emb, - "text": doc.page_content, - "metadata": doc.metadata, - }, - ) + try: + self._client.insert( + table_name=self._collection_name, + data={ + "id": id, + "vector": emb, + "text": doc.page_content, + "metadata": doc.metadata, + }, + ) + except Exception as e: + logger.exception( + "Failed to insert document with id '%s' in collection '%s'", + id, + self._collection_name, + ) + raise Exception(f"Failed to insert document with id '{id}'") from e def text_exists(self, id: str) -> bool: - cur = self._client.get(table_name=self._collection_name, ids=id) - return bool(cur.rowcount != 0) + try: + cur = self._client.get(table_name=self._collection_name, ids=id) + return bool(cur.rowcount != 0) + except Exception as e: + logger.exception( + "Failed to check if text exists with id '%s' in collection '%s'", + id, + self._collection_name, + ) + raise Exception(f"Failed to check text existence for id '{id}'") from e def delete_by_ids(self, ids: list[str]): if not ids: return - self._client.delete(table_name=self._collection_name, ids=ids) + try: + self._client.delete(table_name=self._collection_name, ids=ids) + logger.debug("Deleted %d documents from collection '%s'", len(ids), self._collection_name) + except Exception as e: + logger.exception( + "Failed to delete %d documents from collection '%s'", + len(ids), + self._collection_name, + ) + raise Exception(f"Failed to delete documents from collection '{self._collection_name}'") from e def get_ids_by_metadata_field(self, key: str, value: str) -> list[str]: - from sqlalchemy import text + try: + import re - cur = self._client.get( - table_name=self._collection_name, - ids=None, - where_clause=[text(f"metadata->>'$.{key}' = '{value}'")], - output_column_name=["id"], - ) - return [row[0] for row in cur] + from sqlalchemy import text + + # Validate key to prevent injection in JSON path + if not re.match(r"^[a-zA-Z0-9_.]+$", key): + raise ValueError(f"Invalid characters in metadata key: {key}") + + # Use parameterized query to prevent SQL injection + sql = text(f"SELECT id FROM `{self._collection_name}` WHERE metadata->>'$.{key}' = :value") + + with self._client.engine.connect() as conn: + result = conn.execute(sql, {"value": value}) + ids = [row[0] for row in result] + + logger.debug( + "Found %d documents with metadata field '%s'='%s' in collection '%s'", + len(ids), + key, + value, + self._collection_name, + ) + return ids + except Exception as e: + logger.exception( + "Failed to get IDs by metadata field '%s'='%s' in collection '%s'", + key, + value, + self._collection_name, + ) + raise Exception(f"Failed to query documents by metadata field '{key}'") from e def delete_by_metadata_field(self, key: str, value: str): ids = self.get_ids_by_metadata_field(key, value) - self.delete_by_ids(ids) + if ids: + self.delete_by_ids(ids) + else: + logger.debug("No documents found to delete with metadata field '%s'='%s'", key, value) + + def _process_search_results( + self, results: list[tuple], score_threshold: float = 0.0, score_key: str = "score" + ) -> list[Document]: + """ + Common method to process search results + + :param results: Search results as list of tuples (text, metadata, score) + :param score_threshold: Score threshold for filtering + :param score_key: Key name for score in metadata + :return: List of documents + """ + docs = [] + for row in results: + text, metadata_str, score = row[0], row[1], row[2] + + # Parse metadata JSON + try: + metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else metadata_str + except json.JSONDecodeError: + logger.warning("Invalid JSON metadata: %s", metadata_str) + metadata = {} + + # Add score to metadata + metadata[score_key] = score + + # Filter by score threshold + if score >= score_threshold: + docs.append(Document(page_content=text, metadata=metadata)) + + return docs def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: if not self._hybrid_search_enabled: + logger.warning( + "Full-text search is disabled: set OCEANBASE_ENABLE_HYBRID_SEARCH=true (requires OceanBase >= 4.3.5.1)." + ) + return [] + if not self.field_exists("text"): + logger.warning( + "Full-text search unavailable: collection '%s' missing 'text' field; " + "recreate the collection after enabling OCEANBASE_ENABLE_HYBRID_SEARCH to add fulltext index.", + self._collection_name, + ) return [] try: @@ -220,13 +341,24 @@ class OceanBaseVector(BaseVector): if not isinstance(top_k, int) or top_k <= 0: raise ValueError("top_k must be a positive integer") - document_ids_filter = kwargs.get("document_ids_filter") - where_clause = "" - if document_ids_filter: - document_ids = ", ".join(f"'{id}'" for id in document_ids_filter) - where_clause = f" AND metadata->>'$.document_id' IN ({document_ids})" + score_threshold = float(kwargs.get("score_threshold") or 0.0) - full_sql = f"""SELECT metadata, text, MATCH (text) AGAINST (:query) AS score + # Build parameterized query to prevent SQL injection + from sqlalchemy import text + + document_ids_filter = kwargs.get("document_ids_filter") + params = {"query": query} + where_clause = "" + + if document_ids_filter: + # Create parameterized placeholders for document IDs + placeholders = ", ".join(f":doc_id_{i}" for i in range(len(document_ids_filter))) + where_clause = f" AND metadata->>'$.document_id' IN ({placeholders})" + # Add document IDs to parameters + for i, doc_id in enumerate(document_ids_filter): + params[f"doc_id_{i}"] = doc_id + + full_sql = f"""SELECT text, metadata, MATCH (text) AGAINST (:query) AS score FROM {self._collection_name} WHERE MATCH (text) AGAINST (:query) > 0 {where_clause} @@ -235,35 +367,35 @@ class OceanBaseVector(BaseVector): with self._client.engine.connect() as conn: with conn.begin(): - from sqlalchemy import text - - result = conn.execute(text(full_sql), {"query": query}) + result = conn.execute(text(full_sql), params) rows = result.fetchall() - docs = [] - for row in rows: - metadata_str, _text, score = row - try: - metadata = json.loads(metadata_str) - except json.JSONDecodeError: - logger.warning("Invalid JSON metadata: %s", metadata_str) - metadata = {} - metadata["score"] = score - docs.append(Document(page_content=_text, metadata=metadata)) - - return docs + return self._process_search_results(rows, score_threshold=score_threshold) except Exception as e: - logger.warning("Failed to fulltext search: %s.", str(e)) - return [] + logger.exception( + "Failed to perform full-text search on collection '%s' with query '%s'", + self._collection_name, + query, + ) + raise Exception(f"Full-text search failed for collection '{self._collection_name}'") from e def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]: + from sqlalchemy import text + document_ids_filter = kwargs.get("document_ids_filter") _where_clause = None if document_ids_filter: + # Validate document IDs to prevent SQL injection + # Document IDs should be alphanumeric with hyphens and underscores + import re + + for doc_id in document_ids_filter: + if not isinstance(doc_id, str) or not re.match(r"^[a-zA-Z0-9_-]+$", doc_id): + raise ValueError(f"Invalid document ID format: {doc_id}") + + # Safe to use in query after validation document_ids = ", ".join(f"'{id}'" for id in document_ids_filter) where_clause = f"metadata->>'$.document_id' in ({document_ids})" - from sqlalchemy import text - _where_clause = [text(where_clause)] ef_search = kwargs.get("ef_search", self._hnsw_ef_search) if ef_search != self._hnsw_ef_search: @@ -286,27 +418,27 @@ class OceanBaseVector(BaseVector): where_clause=_where_clause, ) except Exception as e: - raise Exception("Failed to search by vector. ", e) - docs = [] - for _text, metadata, distance in cur: + logger.exception( + "Failed to perform vector search on collection '%s'", + self._collection_name, + ) + raise Exception(f"Vector search failed for collection '{self._collection_name}'") from e + + # Convert distance to score and prepare results for processing + results = [] + for _text, metadata_str, distance in cur: score = 1 - distance / math.sqrt(2) - if score >= score_threshold: - try: - metadata = json.loads(metadata) - except json.JSONDecodeError: - logger.warning("Invalid JSON metadata: %s", metadata) - metadata = {} - metadata["score"] = score - docs.append( - Document( - page_content=_text, - metadata=metadata, - ) - ) - return docs + results.append((_text, metadata_str, score)) + + return self._process_search_results(results, score_threshold=score_threshold) def delete(self): - self._client.drop_table_if_exist(self._collection_name) + try: + self._client.drop_table_if_exist(self._collection_name) + logger.debug("Dropped collection '%s'", self._collection_name) + except Exception as e: + logger.exception("Failed to delete collection '%s'", self._collection_name) + raise Exception(f"Failed to delete collection '{self._collection_name}'") from e class OceanBaseVectorFactory(AbstractVectorFactory): diff --git a/api/tests/unit_tests/core/plugin/test_plugin_runtime.py b/api/tests/unit_tests/core/plugin/test_plugin_runtime.py new file mode 100644 index 0000000000..2a0b293a39 --- /dev/null +++ b/api/tests/unit_tests/core/plugin/test_plugin_runtime.py @@ -0,0 +1,1853 @@ +"""Comprehensive unit tests for Plugin Runtime functionality. + +This test module covers all aspects of plugin runtime including: +- Plugin execution through the plugin daemon +- Sandbox isolation via HTTP communication +- Resource limits (timeout, memory constraints) +- Error handling for various failure scenarios +- Plugin communication (request/response patterns, streaming) + +All tests use mocking to avoid external dependencies and ensure fast, reliable execution. +Tests follow the Arrange-Act-Assert pattern for clarity. +""" + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from pydantic import BaseModel + +from core.model_runtime.errors.invoke import ( + InvokeAuthorizationError, + InvokeBadRequestError, + InvokeConnectionError, + InvokeRateLimitError, + InvokeServerUnavailableError, +) +from core.model_runtime.errors.validate import CredentialsValidateFailedError +from core.plugin.entities.plugin_daemon import ( + CredentialType, + PluginDaemonInnerError, +) +from core.plugin.impl.base import BasePluginClient +from core.plugin.impl.exc import ( + PluginDaemonBadRequestError, + PluginDaemonInternalServerError, + PluginDaemonNotFoundError, + PluginDaemonUnauthorizedError, + PluginInvokeError, + PluginNotFoundError, + PluginPermissionDeniedError, + PluginUniqueIdentifierError, +) +from core.plugin.impl.plugin import PluginInstaller +from core.plugin.impl.tool import PluginToolManager + + +class TestPluginRuntimeExecution: + """Unit tests for plugin execution functionality. + + Tests cover: + - Successful plugin invocation + - Request preparation and headers + - Response parsing + - Streaming responses + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-api-key"), + ): + yield + + def test_request_preparation(self, plugin_client, mock_config): + """Test that requests are properly prepared with correct headers and URL.""" + # Arrange + path = "plugin/test-tenant/management/list" + headers = {"Custom-Header": "value"} + data = {"key": "value"} + params = {"page": 1} + + # Act + url, prepared_headers, prepared_data, prepared_params, files = plugin_client._prepare_request( + path, headers, data, params, None + ) + + # Assert + assert url == "http://127.0.0.1:5002/plugin/test-tenant/management/list" + assert prepared_headers["X-Api-Key"] == "test-api-key" + assert prepared_headers["Custom-Header"] == "value" + assert prepared_headers["Accept-Encoding"] == "gzip, deflate, br" + assert prepared_data == data + assert prepared_params == params + + def test_request_with_json_content_type(self, plugin_client, mock_config): + """Test request preparation with JSON content type.""" + # Arrange + path = "plugin/test-tenant/management/install" + headers = {"Content-Type": "application/json"} + data = {"plugin_id": "test-plugin"} + + # Act + url, prepared_headers, prepared_data, prepared_params, files = plugin_client._prepare_request( + path, headers, data, None, None + ) + + # Assert + assert prepared_headers["Content-Type"] == "application/json" + assert prepared_data == json.dumps(data) + + def test_successful_request_execution(self, plugin_client, mock_config): + """Test successful HTTP request execution.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "success"} + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + response = plugin_client._request("GET", "plugin/test-tenant/management/list") + + # Assert + assert response.status_code == 200 + mock_request.assert_called_once() + call_kwargs = mock_request.call_args[1] + assert call_kwargs["method"] == "GET" + assert "http://127.0.0.1:5002/plugin/test-tenant/management/list" in call_kwargs["url"] + assert call_kwargs["headers"]["X-Api-Key"] == "test-api-key" + + def test_request_with_timeout_configuration(self, plugin_client, mock_config): + """Test that timeout configuration is properly applied.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("GET", "plugin/test-tenant/test") + + # Assert + call_kwargs = mock_request.call_args[1] + assert "timeout" in call_kwargs + + def test_request_connection_error(self, plugin_client, mock_config): + """Test handling of connection errors during request.""" + # Arrange + with patch("httpx.request", side_effect=httpx.RequestError("Connection failed")): + # Act & Assert + with pytest.raises(PluginDaemonInnerError) as exc_info: + plugin_client._request("GET", "plugin/test-tenant/test") + assert exc_info.value.code == -500 + assert "Request to Plugin Daemon Service failed" in exc_info.value.message + + +class TestPluginRuntimeSandboxIsolation: + """Unit tests for plugin sandbox isolation. + + Tests cover: + - Isolated execution environment via HTTP + - API key authentication + - Request/response boundaries + - Plugin daemon communication protocol + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "secure-api-key"), + ): + yield + + def test_api_key_authentication(self, plugin_client, mock_config): + """Test that all requests include API key for authentication.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": True} + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("GET", "plugin/test-tenant/test") + + # Assert + call_kwargs = mock_request.call_args[1] + assert call_kwargs["headers"]["X-Api-Key"] == "secure-api-key" + + def test_isolated_plugin_execution_via_http(self, plugin_client, mock_config): + """Test that plugin execution is isolated via HTTP communication.""" + + # Arrange + class TestResponse(BaseModel): + result: str + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": {"result": "isolated_execution"}} + + with patch("httpx.request", return_value=mock_response): + # Act + result = plugin_client._request_with_plugin_daemon_response( + "POST", "plugin/test-tenant/dispatch/tool/invoke", TestResponse, data={"tool": "test"} + ) + + # Assert + assert result.result == "isolated_execution" + + def test_plugin_daemon_unauthorized_error(self, plugin_client, mock_config): + """Test handling of unauthorized access to plugin daemon.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps({"error_type": "PluginDaemonUnauthorizedError", "message": "Unauthorized access"}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginDaemonUnauthorizedError) as exc_info: + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + assert "Unauthorized access" in exc_info.value.description + + def test_plugin_permission_denied(self, plugin_client, mock_config): + """Test handling of permission denied errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps( + {"error_type": "PluginPermissionDeniedError", "message": "Permission denied for this operation"} + ) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginPermissionDeniedError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/test", bool) + assert "Permission denied" in exc_info.value.description + + +class TestPluginRuntimeResourceLimits: + """Unit tests for plugin resource limits. + + Tests cover: + - Timeout enforcement + - Memory constraints + - Resource limit violations + - Graceful degradation + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration with timeout.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + patch("core.plugin.impl.base.plugin_daemon_request_timeout", httpx.Timeout(30.0)), + ): + yield + + def test_timeout_configuration_applied(self, plugin_client, mock_config): + """Test that timeout configuration is properly applied to requests.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("GET", "plugin/test-tenant/test") + + # Assert + call_kwargs = mock_request.call_args[1] + assert call_kwargs["timeout"] is not None + + def test_timeout_error_handling(self, plugin_client, mock_config): + """Test handling of timeout errors.""" + # Arrange + with patch("httpx.request", side_effect=httpx.TimeoutException("Request timeout")): + # Act & Assert + with pytest.raises(PluginDaemonInnerError) as exc_info: + plugin_client._request("GET", "plugin/test-tenant/test") + assert exc_info.value.code == -500 + + def test_streaming_request_timeout(self, plugin_client, mock_config): + """Test timeout handling for streaming requests.""" + # Arrange + with patch("httpx.stream", side_effect=httpx.TimeoutException("Stream timeout")): + # Act & Assert + with pytest.raises(PluginDaemonInnerError) as exc_info: + list(plugin_client._stream_request("POST", "plugin/test-tenant/stream")) + assert exc_info.value.code == -500 + + def test_resource_limit_error_from_daemon(self, plugin_client, mock_config): + """Test handling of resource limit errors from plugin daemon.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps( + {"error_type": "PluginDaemonInternalServerError", "message": "Resource limit exceeded"} + ) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginDaemonInternalServerError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/test", bool) + assert "Resource limit exceeded" in exc_info.value.description + + +class TestPluginRuntimeErrorHandling: + """Unit tests for plugin runtime error handling. + + Tests cover: + - Various error types (invoke, validation, connection) + - Error propagation and transformation + - User-friendly error messages + - Error recovery mechanisms + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_plugin_invoke_rate_limit_error(self, plugin_client, mock_config): + """Test handling of rate limit errors during plugin invocation.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + invoke_error = { + "error_type": "InvokeRateLimitError", + "args": {"description": "Rate limit exceeded"}, + } + error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(InvokeRateLimitError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/invoke", bool) + assert "Rate limit exceeded" in exc_info.value.description + + def test_plugin_invoke_authorization_error(self, plugin_client, mock_config): + """Test handling of authorization errors during plugin invocation.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + invoke_error = { + "error_type": "InvokeAuthorizationError", + "args": {"description": "Invalid credentials"}, + } + error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(InvokeAuthorizationError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/invoke", bool) + assert "Invalid credentials" in exc_info.value.description + + def test_plugin_invoke_bad_request_error(self, plugin_client, mock_config): + """Test handling of bad request errors during plugin invocation.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + invoke_error = { + "error_type": "InvokeBadRequestError", + "args": {"description": "Invalid parameters"}, + } + error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(InvokeBadRequestError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/invoke", bool) + assert "Invalid parameters" in exc_info.value.description + + def test_plugin_invoke_connection_error(self, plugin_client, mock_config): + """Test handling of connection errors during plugin invocation.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + invoke_error = { + "error_type": "InvokeConnectionError", + "args": {"description": "Connection to external service failed"}, + } + error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(InvokeConnectionError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/invoke", bool) + assert "Connection to external service failed" in exc_info.value.description + + def test_plugin_invoke_server_unavailable_error(self, plugin_client, mock_config): + """Test handling of server unavailable errors during plugin invocation.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + invoke_error = { + "error_type": "InvokeServerUnavailableError", + "args": {"description": "Service temporarily unavailable"}, + } + error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(InvokeServerUnavailableError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/invoke", bool) + assert "Service temporarily unavailable" in exc_info.value.description + + def test_credentials_validation_error(self, plugin_client, mock_config): + """Test handling of credential validation errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + invoke_error = { + "error_type": "CredentialsValidateFailedError", + "message": "Invalid API key format", + } + error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(CredentialsValidateFailedError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/validate", bool) + assert "Invalid API key format" in str(exc_info.value) + + def test_plugin_not_found_error(self, plugin_client, mock_config): + """Test handling of plugin not found errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps( + {"error_type": "PluginNotFoundError", "message": "Plugin with ID 'test-plugin' not found"} + ) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginNotFoundError) as exc_info: + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/get", bool) + assert "Plugin with ID 'test-plugin' not found" in exc_info.value.description + + def test_plugin_unique_identifier_error(self, plugin_client, mock_config): + """Test handling of unique identifier errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps( + {"error_type": "PluginUniqueIdentifierError", "message": "Invalid plugin identifier format"} + ) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginUniqueIdentifierError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/install", bool) + assert "Invalid plugin identifier format" in exc_info.value.description + + def test_daemon_bad_request_error(self, plugin_client, mock_config): + """Test handling of daemon bad request errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps( + {"error_type": "PluginDaemonBadRequestError", "message": "Missing required parameter"} + ) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginDaemonBadRequestError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/test", bool) + assert "Missing required parameter" in exc_info.value.description + + def test_daemon_not_found_error(self, plugin_client, mock_config): + """Test handling of daemon not found errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps({"error_type": "PluginDaemonNotFoundError", "message": "Resource not found"}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginDaemonNotFoundError) as exc_info: + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/resource", bool) + assert "Resource not found" in exc_info.value.description + + def test_generic_plugin_invoke_error(self, plugin_client, mock_config): + """Test handling of generic plugin invoke errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + # Create a proper nested JSON structure for PluginInvokeError + invoke_error_message = json.dumps( + {"error_type": "UnknownInvokeError", "message": "Generic plugin execution error"} + ) + error_message = json.dumps({"error_type": "PluginInvokeError", "message": invoke_error_message}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginInvokeError) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/invoke", bool) + assert exc_info.value.description is not None + + def test_unknown_error_type(self, plugin_client, mock_config): + """Test handling of unknown error types.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps({"error_type": "UnknownErrorType", "message": "Unknown error occurred"}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(Exception) as exc_info: + plugin_client._request_with_plugin_daemon_response("POST", "plugin/test-tenant/test", bool) + assert "got unknown error from plugin daemon" in str(exc_info.value) + + def test_http_status_error_handling(self, plugin_client, mock_config): + """Test handling of HTTP status errors.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server Error", request=MagicMock(), response=mock_response + ) + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(httpx.HTTPStatusError): + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + + def test_empty_data_response_error(self, plugin_client, mock_config): + """Test handling of empty data in successful response.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(ValueError) as exc_info: + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + assert "got empty data from plugin daemon" in str(exc_info.value) + + +class TestPluginRuntimeCommunication: + """Unit tests for plugin communication patterns. + + Tests cover: + - Request/response communication + - Streaming responses + - Data serialization/deserialization + - Message formatting + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_request_response_communication(self, plugin_client, mock_config): + """Test basic request/response communication pattern.""" + + # Arrange + class TestModel(BaseModel): + value: str + count: int + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": {"value": "test", "count": 42}} + + with patch("httpx.request", return_value=mock_response): + # Act + result = plugin_client._request_with_plugin_daemon_response( + "POST", "plugin/test-tenant/test", TestModel, data={"input": "data"} + ) + + # Assert + assert isinstance(result, TestModel) + assert result.value == "test" + assert result.count == 42 + + def test_streaming_response_communication(self, plugin_client, mock_config): + """Test streaming response communication pattern.""" + + # Arrange + class StreamModel(BaseModel): + chunk: str + + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"chunk": "first"}}', + 'data: {"code": 0, "message": "", "data": {"chunk": "second"}}', + 'data: {"code": 0, "message": "", "data": {"chunk": "third"}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/stream", StreamModel + ) + ) + + # Assert + assert len(results) == 3 + assert all(isinstance(r, StreamModel) for r in results) + assert results[0].chunk == "first" + assert results[1].chunk == "second" + assert results[2].chunk == "third" + + def test_streaming_with_error_in_stream(self, plugin_client, mock_config): + """Test error handling in streaming responses.""" + # Arrange + # Create proper error structure for -500 code + error_obj = json.dumps({"error_type": "PluginDaemonInnerError", "message": "Stream error occurred"}) + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"chunk": "first"}}', + f'data: {{"code": -500, "message": {json.dumps(error_obj)}, "data": null}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + class StreamModel(BaseModel): + chunk: str + + results = plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/stream", StreamModel + ) + + # Assert + first_result = next(results) + assert first_result.chunk == "first" + + with pytest.raises(PluginDaemonInnerError) as exc_info: + next(results) + assert exc_info.value.code == -500 + + def test_streaming_connection_error(self, plugin_client, mock_config): + """Test connection error during streaming.""" + # Arrange + with patch("httpx.stream", side_effect=httpx.RequestError("Stream connection failed")): + # Act & Assert + with pytest.raises(PluginDaemonInnerError) as exc_info: + list(plugin_client._stream_request("POST", "plugin/test-tenant/stream")) + assert exc_info.value.code == -500 + + def test_request_with_model_parsing(self, plugin_client, mock_config): + """Test request with direct model parsing (without daemon response wrapper).""" + + # Arrange + class DirectModel(BaseModel): + status: str + data: dict[str, Any] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "success", "data": {"key": "value"}} + + with patch("httpx.request", return_value=mock_response): + # Act + result = plugin_client._request_with_model("GET", "plugin/test-tenant/direct", DirectModel) + + # Assert + assert isinstance(result, DirectModel) + assert result.status == "success" + assert result.data == {"key": "value"} + + def test_streaming_with_model_parsing(self, plugin_client, mock_config): + """Test streaming with direct model parsing.""" + + # Arrange + class StreamItem(BaseModel): + id: int + text: str + + stream_data = [ + '{"id": 1, "text": "first"}', + '{"id": 2, "text": "second"}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list(plugin_client._stream_request_with_model("POST", "plugin/test-tenant/stream", StreamItem)) + + # Assert + assert len(results) == 2 + assert results[0].id == 1 + assert results[0].text == "first" + assert results[1].id == 2 + assert results[1].text == "second" + + def test_streaming_skips_empty_lines(self, plugin_client, mock_config): + """Test that streaming properly skips empty lines.""" + + # Arrange + class StreamModel(BaseModel): + value: str + + stream_data = [ + "", + '{"code": 0, "message": "", "data": {"value": "first"}}', + "", + "", + '{"code": 0, "message": "", "data": {"value": "second"}}', + "", + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/stream", StreamModel + ) + ) + + # Assert + assert len(results) == 2 + assert results[0].value == "first" + assert results[1].value == "second" + + +class TestPluginToolManagerIntegration: + """Integration tests for PluginToolManager. + + Tests cover: + - Tool invocation + - Credential validation + - Runtime parameter retrieval + - Tool provider management + """ + + @pytest.fixture + def tool_manager(self): + """Create a PluginToolManager instance for testing.""" + return PluginToolManager() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_tool_invocation_success(self, tool_manager, mock_config): + """Test successful tool invocation.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"type": "text", "message": {"text": "Result"}}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + tool_manager.invoke( + tenant_id="test-tenant", + user_id="test-user", + tool_provider="langgenius/test-plugin/test-provider", + tool_name="test-tool", + credentials={"api_key": "test-key"}, + credential_type=CredentialType.API_KEY, + tool_parameters={"param1": "value1"}, + ) + ) + + # Assert + assert len(results) > 0 + assert results[0].type == "text" + + def test_validate_provider_credentials_success(self, tool_manager, mock_config): + """Test successful provider credential validation.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"result": true}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + result = tool_manager.validate_provider_credentials( + tenant_id="test-tenant", + user_id="test-user", + provider="langgenius/test-plugin/test-provider", + credentials={"api_key": "valid-key"}, + ) + + # Assert + assert result is True + + def test_validate_provider_credentials_failure(self, tool_manager, mock_config): + """Test failed provider credential validation.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"result": false}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + result = tool_manager.validate_provider_credentials( + tenant_id="test-tenant", + user_id="test-user", + provider="langgenius/test-plugin/test-provider", + credentials={"api_key": "invalid-key"}, + ) + + # Assert + assert result is False + + def test_validate_datasource_credentials_success(self, tool_manager, mock_config): + """Test successful datasource credential validation.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"result": true}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + result = tool_manager.validate_datasource_credentials( + tenant_id="test-tenant", + user_id="test-user", + provider="langgenius/test-plugin/test-datasource", + credentials={"connection_string": "valid"}, + ) + + # Assert + assert result is True + + +class TestPluginInstallerIntegration: + """Integration tests for PluginInstaller. + + Tests cover: + - Plugin installation + - Plugin listing + - Plugin uninstallation + - Package upload + """ + + @pytest.fixture + def installer(self): + """Create a PluginInstaller instance for testing.""" + return PluginInstaller() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_list_plugins_success(self, installer, mock_config): + """Test successful plugin listing.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "code": 0, + "message": "", + "data": { + "list": [], + "total": 0, + }, + } + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.list_plugins("test-tenant") + + # Assert + assert isinstance(result, list) + + def test_uninstall_plugin_success(self, installer, mock_config): + """Test successful plugin uninstallation.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": True} + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.uninstall("test-tenant", "plugin-installation-id") + + # Assert + assert result is True + + def test_fetch_plugin_by_identifier_success(self, installer, mock_config): + """Test successful plugin fetch by identifier.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": True} + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.fetch_plugin_by_identifier("test-tenant", "plugin-identifier") + + # Assert + assert result is True + + +class TestPluginRuntimeEdgeCases: + """Tests for edge cases and corner scenarios in plugin runtime. + + Tests cover: + - Malformed responses + - Unexpected data types + - Concurrent requests + - Large payloads + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_malformed_json_response(self, plugin_client, mock_config): + """Test handling of malformed JSON responses.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0) + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(ValueError): + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + + def test_invalid_response_structure(self, plugin_client, mock_config): + """Test handling of invalid response structure.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + # Missing required fields in response + mock_response.json.return_value = {"invalid": "structure"} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(ValueError): + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + + def test_streaming_with_invalid_json_line(self, plugin_client, mock_config): + """Test streaming with invalid JSON in one line.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"value": "valid"}}', + "data: {invalid json}", + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + class StreamModel(BaseModel): + value: str + + results = plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/stream", StreamModel + ) + + # Assert + first_result = next(results) + assert first_result.value == "valid" + + with pytest.raises(ValueError): + next(results) + + def test_request_with_bytes_data(self, plugin_client, mock_config): + """Test request with bytes data.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("POST", "plugin/test-tenant/upload", data=b"binary data") + + # Assert + call_kwargs = mock_request.call_args[1] + assert call_kwargs["content"] == b"binary data" + + def test_request_with_files(self, plugin_client, mock_config): + """Test request with file upload.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + + files = {"file": ("test.txt", b"file content", "text/plain")} + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("POST", "plugin/test-tenant/upload", files=files) + + # Assert + call_kwargs = mock_request.call_args[1] + assert call_kwargs["files"] == files + + def test_streaming_empty_response(self, plugin_client, mock_config): + """Test streaming with empty response.""" + # Arrange + mock_response = MagicMock() + mock_response.iter_lines.return_value = [] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list(plugin_client._stream_request("POST", "plugin/test-tenant/stream")) + + # Assert + assert len(results) == 0 + + def test_daemon_inner_error_with_code_500(self, plugin_client, mock_config): + """Test handling of daemon inner error with code -500 in stream.""" + # Arrange + error_obj = json.dumps({"error_type": "PluginDaemonInnerError", "message": "Internal error"}) + stream_data = [ + f'data: {{"code": -500, "message": {json.dumps(error_obj)}, "data": null}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act & Assert + class StreamModel(BaseModel): + data: str + + results = plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/stream", StreamModel + ) + with pytest.raises(PluginDaemonInnerError) as exc_info: + next(results) + assert exc_info.value.code == -500 + + def test_non_json_error_message(self, plugin_client, mock_config): + """Test handling of non-JSON error message.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": -1, "message": "Plain text error message", "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(ValueError) as exc_info: + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + assert "Plain text error message" in str(exc_info.value) + + +class TestPluginRuntimeAdvancedScenarios: + """Advanced test scenarios for plugin runtime. + + Tests cover: + - Complex error recovery + - Concurrent request handling + - Plugin state management + - Advanced streaming patterns + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_multiple_sequential_requests(self, plugin_client, mock_config): + """Test multiple sequential requests to the same endpoint.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": True} + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + for i in range(5): + result = plugin_client._request_with_plugin_daemon_response("GET", f"plugin/test-tenant/test/{i}", bool) + assert result is True + + # Assert + assert mock_request.call_count == 5 + + def test_request_with_complex_nested_data(self, plugin_client, mock_config): + """Test request with complex nested data structures.""" + + # Arrange + class ComplexModel(BaseModel): + nested: dict[str, Any] + items: list[dict[str, Any]] + + complex_data = { + "nested": {"level1": {"level2": {"level3": "deep_value"}}}, + "items": [ + {"id": 1, "name": "item1"}, + {"id": 2, "name": "item2"}, + ], + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": complex_data} + + with patch("httpx.request", return_value=mock_response): + # Act + result = plugin_client._request_with_plugin_daemon_response( + "POST", "plugin/test-tenant/complex", ComplexModel + ) + + # Assert + assert result.nested["level1"]["level2"]["level3"] == "deep_value" + assert len(result.items) == 2 + assert result.items[0]["id"] == 1 + + def test_streaming_with_multiple_chunk_types(self, plugin_client, mock_config): + """Test streaming with different chunk types in sequence.""" + + # Arrange + class MultiTypeModel(BaseModel): + type: str + data: dict[str, Any] + + stream_data = [ + '{"code": 0, "message": "", "data": {"type": "start", "data": {"status": "initializing"}}}', + '{"code": 0, "message": "", "data": {"type": "progress", "data": {"percent": 50}}}', + '{"code": 0, "message": "", "data": {"type": "complete", "data": {"result": "success"}}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/multi-stream", MultiTypeModel + ) + ) + + # Assert + assert len(results) == 3 + assert results[0].type == "start" + assert results[1].type == "progress" + assert results[2].type == "complete" + assert results[1].data["percent"] == 50 + + def test_error_recovery_with_retry_pattern(self, plugin_client, mock_config): + """Test error recovery pattern (simulated retry logic).""" + # Arrange + call_count = 0 + + def side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise httpx.RequestError("Temporary failure") + mock_response = MagicMock() + mock_response.status_code = 200 + return mock_response + + with patch("httpx.request", side_effect=side_effect): + # Act & Assert - First two calls should fail + with pytest.raises(PluginDaemonInnerError): + plugin_client._request("GET", "plugin/test-tenant/test") + + with pytest.raises(PluginDaemonInnerError): + plugin_client._request("GET", "plugin/test-tenant/test") + + # Third call should succeed + response = plugin_client._request("GET", "plugin/test-tenant/test") + assert response.status_code == 200 + + def test_request_with_custom_headers_preservation(self, plugin_client, mock_config): + """Test that custom headers are preserved through request pipeline.""" + # Arrange + custom_headers = { + "X-Custom-Header": "custom-value", + "X-Request-ID": "req-123", + "X-Tenant-ID": "tenant-456", + } + + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("GET", "plugin/test-tenant/test", headers=custom_headers) + + # Assert + call_kwargs = mock_request.call_args[1] + for key, value in custom_headers.items(): + assert call_kwargs["headers"][key] == value + + def test_streaming_with_large_chunks(self, plugin_client, mock_config): + """Test streaming with large data chunks.""" + + # Arrange + class LargeChunkModel(BaseModel): + chunk_id: int + data: str + + # Create large chunks (simulating large data transfer) + large_data = "x" * 10000 # 10KB of data + stream_data = [ + f'{{"code": 0, "message": "", "data": {{"chunk_id": {i}, "data": "{large_data}"}}}}' for i in range(10) + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/large-stream", LargeChunkModel + ) + ) + + # Assert + assert len(results) == 10 + for i, result in enumerate(results): + assert result.chunk_id == i + assert len(result.data) == 10000 + + +class TestPluginRuntimeSecurityAndValidation: + """Tests for security and validation aspects of plugin runtime. + + Tests cover: + - Input validation + - Security headers + - Authentication failures + - Authorization checks + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "secure-key-123"), + ): + yield + + def test_api_key_header_always_present(self, plugin_client, mock_config): + """Test that API key header is always included in requests.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request("GET", "plugin/test-tenant/test") + + # Assert + call_kwargs = mock_request.call_args[1] + assert "X-Api-Key" in call_kwargs["headers"] + assert call_kwargs["headers"]["X-Api-Key"] == "secure-key-123" + + def test_request_with_sensitive_data_in_body(self, plugin_client, mock_config): + """Test handling of sensitive data in request body.""" + # Arrange + sensitive_data = { + "api_key": "secret-api-key", + "password": "secret-password", + "credentials": {"token": "secret-token"}, + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": True} + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request_with_plugin_daemon_response( + "POST", + "plugin/test-tenant/validate", + bool, + data=sensitive_data, + headers={"Content-Type": "application/json"}, + ) + + # Assert - Verify data was sent + call_kwargs = mock_request.call_args[1] + assert "content" in call_kwargs or "data" in call_kwargs + + def test_unauthorized_access_with_invalid_key(self, plugin_client, mock_config): + """Test handling of unauthorized access with invalid API key.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps({"error_type": "PluginDaemonUnauthorizedError", "message": "Invalid API key"}) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginDaemonUnauthorizedError) as exc_info: + plugin_client._request_with_plugin_daemon_response("GET", "plugin/test-tenant/test", bool) + assert "Invalid API key" in exc_info.value.description + + def test_request_parameter_validation(self, plugin_client, mock_config): + """Test validation of request parameters.""" + # Arrange + invalid_params = { + "page": -1, # Invalid negative page + "limit": 0, # Invalid zero limit + } + + mock_response = MagicMock() + mock_response.status_code = 200 + error_message = json.dumps( + {"error_type": "PluginDaemonBadRequestError", "message": "Invalid parameters: page must be positive"} + ) + mock_response.json.return_value = {"code": -1, "message": error_message, "data": None} + + with patch("httpx.request", return_value=mock_response): + # Act & Assert + with pytest.raises(PluginDaemonBadRequestError) as exc_info: + plugin_client._request_with_plugin_daemon_response( + "GET", "plugin/test-tenant/list", list, params=invalid_params + ) + assert "Invalid parameters" in exc_info.value.description + + def test_content_type_header_validation(self, plugin_client, mock_config): + """Test that Content-Type header is properly set for JSON requests.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.request", return_value=mock_response) as mock_request: + # Act + plugin_client._request( + "POST", "plugin/test-tenant/test", headers={"Content-Type": "application/json"}, data={"key": "value"} + ) + + # Assert + call_kwargs = mock_request.call_args[1] + assert call_kwargs["headers"]["Content-Type"] == "application/json" + + +class TestPluginRuntimePerformanceScenarios: + """Tests for performance-related scenarios in plugin runtime. + + Tests cover: + - High-volume streaming + - Concurrent operations simulation + - Memory-efficient processing + - Timeout handling under load + """ + + @pytest.fixture + def plugin_client(self): + """Create a BasePluginClient instance for testing.""" + return BasePluginClient() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_high_volume_streaming(self, plugin_client, mock_config): + """Test streaming with high volume of chunks.""" + + # Arrange + class StreamChunk(BaseModel): + index: int + value: str + + # Generate 100 chunks + stream_data = [ + f'{{"code": 0, "message": "", "data": {{"index": {i}, "value": "chunk_{i}"}}}}' for i in range(100) + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/high-volume", StreamChunk + ) + ) + + # Assert + assert len(results) == 100 + assert results[0].index == 0 + assert results[99].index == 99 + assert results[50].value == "chunk_50" + + def test_streaming_memory_efficiency(self, plugin_client, mock_config): + """Test that streaming processes chunks one at a time (memory efficient).""" + + # Arrange + class ChunkModel(BaseModel): + data: str + + processed_chunks = [] + + def process_chunk(chunk): + """Simulate processing each chunk individually.""" + processed_chunks.append(chunk.data) + return chunk + + stream_data = [f'{{"code": 0, "message": "", "data": {{"data": "chunk_{i}"}}}}' for i in range(10)] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act - Process chunks one by one + for chunk in plugin_client._request_with_plugin_daemon_response_stream( + "POST", "plugin/test-tenant/stream", ChunkModel + ): + process_chunk(chunk) + + # Assert + assert len(processed_chunks) == 10 + + def test_timeout_with_slow_response(self, plugin_client, mock_config): + """Test timeout handling with slow response simulation.""" + # Arrange + with patch("httpx.request", side_effect=httpx.TimeoutException("Request timed out after 30s")): + # Act & Assert + with pytest.raises(PluginDaemonInnerError) as exc_info: + plugin_client._request("GET", "plugin/test-tenant/slow-endpoint") + assert exc_info.value.code == -500 + + def test_concurrent_request_simulation(self, plugin_client, mock_config): + """Test simulation of concurrent requests (sequential execution in test).""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": True} + + request_results = [] + + with patch("httpx.request", return_value=mock_response): + # Act - Simulate 10 concurrent requests + for i in range(10): + result = plugin_client._request_with_plugin_daemon_response( + "GET", f"plugin/test-tenant/concurrent/{i}", bool + ) + request_results.append(result) + + # Assert + assert len(request_results) == 10 + assert all(result is True for result in request_results) + + +class TestPluginToolManagerAdvanced: + """Advanced tests for PluginToolManager functionality. + + Tests cover: + - Complex tool invocations + - Runtime parameter handling + - Tool provider discovery + - Advanced credential scenarios + """ + + @pytest.fixture + def tool_manager(self): + """Create a PluginToolManager instance for testing.""" + return PluginToolManager() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_tool_invocation_with_complex_parameters(self, tool_manager, mock_config): + """Test tool invocation with complex parameter structures.""" + # Arrange + complex_params = { + "simple_string": "value", + "number": 42, + "boolean": True, + "nested_object": {"key1": "value1", "key2": ["item1", "item2"]}, + "array": [1, 2, 3, 4, 5], + } + + stream_data = [ + ( + 'data: {"code": 0, "message": "", "data": {"type": "text", ' + '"message": {"text": "Complex params processed"}}}' + ), + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + tool_manager.invoke( + tenant_id="test-tenant", + user_id="test-user", + tool_provider="langgenius/test-plugin/test-provider", + tool_name="complex-tool", + credentials={"api_key": "test-key"}, + credential_type=CredentialType.API_KEY, + tool_parameters=complex_params, + ) + ) + + # Assert + assert len(results) > 0 + + def test_tool_invocation_with_conversation_context(self, tool_manager, mock_config): + """Test tool invocation with conversation context.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"type": "text", "message": {"text": "Context-aware result"}}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + results = list( + tool_manager.invoke( + tenant_id="test-tenant", + user_id="test-user", + tool_provider="langgenius/test-plugin/test-provider", + tool_name="test-tool", + credentials={"api_key": "test-key"}, + credential_type=CredentialType.API_KEY, + tool_parameters={"query": "test"}, + conversation_id="conv-123", + app_id="app-456", + message_id="msg-789", + ) + ) + + # Assert + assert len(results) > 0 + + def test_get_runtime_parameters_success(self, tool_manager, mock_config): + """Test successful retrieval of runtime parameters.""" + # Arrange + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"parameters": []}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + result = tool_manager.get_runtime_parameters( + tenant_id="test-tenant", + user_id="test-user", + provider="langgenius/test-plugin/test-provider", + credentials={"api_key": "test-key"}, + tool="test-tool", + ) + + # Assert + assert isinstance(result, list) + + def test_validate_credentials_with_oauth(self, tool_manager, mock_config): + """Test credential validation with OAuth credentials.""" + # Arrange + oauth_credentials = { + "access_token": "oauth-token-123", + "refresh_token": "refresh-token-456", + "expires_at": 1234567890, + } + + stream_data = [ + 'data: {"code": 0, "message": "", "data": {"result": true}}', + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data] + + with patch("httpx.stream") as mock_stream: + mock_stream.return_value.__enter__.return_value = mock_response + + # Act + result = tool_manager.validate_provider_credentials( + tenant_id="test-tenant", + user_id="test-user", + provider="langgenius/test-plugin/oauth-provider", + credentials=oauth_credentials, + ) + + # Assert + assert result is True + + +class TestPluginInstallerAdvanced: + """Advanced tests for PluginInstaller functionality. + + Tests cover: + - Plugin package upload + - Bundle installation + - Plugin upgrade scenarios + - Dependency management + """ + + @pytest.fixture + def installer(self): + """Create a PluginInstaller instance for testing.""" + return PluginInstaller() + + @pytest.fixture + def mock_config(self): + """Mock plugin daemon configuration.""" + with ( + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), + patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-key"), + ): + yield + + def test_upload_plugin_package_success(self, installer, mock_config): + """Test successful plugin package upload.""" + # Arrange + plugin_package = b"fake-plugin-package-data" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "code": 0, + "message": "", + "data": { + "unique_identifier": "test-org/test-plugin", + "manifest": { + "version": "1.0.0", + "author": "test-org", + "name": "test-plugin", + "description": {"en_US": "Test plugin"}, + "icon": "icon.png", + "label": {"en_US": "Test Plugin"}, + "created_at": "2024-01-01T00:00:00Z", + "resource": {"memory": 256}, + "plugins": {}, + "meta": {}, + }, + "verification": None, + }, + } + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.upload_pkg("test-tenant", plugin_package, verify_signature=False) + + # Assert + assert result.unique_identifier == "test-org/test-plugin" + + def test_fetch_plugin_readme_success(self, installer, mock_config): + """Test successful plugin readme fetch.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "code": 0, + "message": "", + "data": {"content": "# Plugin README\n\nThis is a test plugin.", "language": "en"}, + } + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.fetch_plugin_readme("test-tenant", "test-org/test-plugin", "en") + + # Assert + assert "Plugin README" in result + assert "test plugin" in result + + def test_fetch_plugin_readme_not_found(self, installer, mock_config): + """Test plugin readme fetch when readme doesn't exist.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 404 + + def raise_for_status(): + raise httpx.HTTPStatusError("Not Found", request=MagicMock(), response=mock_response) + + mock_response.raise_for_status = raise_for_status + + with patch("httpx.request", return_value=mock_response): + # Act & Assert - Should raise HTTPStatusError for 404 + with pytest.raises(httpx.HTTPStatusError): + installer.fetch_plugin_readme("test-tenant", "test-org/test-plugin", "en") + + def test_list_plugins_with_pagination(self, installer, mock_config): + """Test plugin listing with pagination.""" + # Arrange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "code": 0, + "message": "", + "data": { + "list": [], + "total": 50, + }, + } + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.list_plugins_with_total("test-tenant", page=2, page_size=20) + + # Assert + assert result.total == 50 + assert isinstance(result.list, list) + + def test_check_tools_existence(self, installer, mock_config): + """Test checking existence of multiple tools.""" + # Arrange + from models.provider_ids import GenericProviderID + + provider_ids = [ + GenericProviderID("langgenius/plugin1/provider1"), + GenericProviderID("langgenius/plugin2/provider2"), + ] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"code": 0, "message": "", "data": [True, False]} + + with patch("httpx.request", return_value=mock_response): + # Act + result = installer.check_tools_existence("test-tenant", provider_ids) + + # Assert + assert len(result) == 2 + assert result[0] is True + assert result[1] is False diff --git a/web/hooks/use-tab-searchparams.spec.ts b/web/hooks/use-tab-searchparams.spec.ts index 62adea529f..7e0cc40d21 100644 --- a/web/hooks/use-tab-searchparams.spec.ts +++ b/web/hooks/use-tab-searchparams.spec.ts @@ -116,7 +116,7 @@ describe('useTabSearchParams', () => { setActiveTab('settings') }) - expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false }) expect(mockReplace).not.toHaveBeenCalled() }) @@ -137,7 +137,7 @@ describe('useTabSearchParams', () => { setActiveTab('settings') }) - expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings') + expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false }) expect(mockPush).not.toHaveBeenCalled() }) @@ -157,6 +157,7 @@ describe('useTabSearchParams', () => { expect(mockPush).toHaveBeenCalledWith( '/test-path?category=settings%20%26%20config', + { scroll: false }, ) }) @@ -211,7 +212,7 @@ describe('useTabSearchParams', () => { setActiveTab('profile') }) - expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile') + expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile', { scroll: false }) }) }) @@ -294,7 +295,7 @@ describe('useTabSearchParams', () => { const [activeTab] = result.current expect(activeTab).toBe('') - expect(mockPush).toHaveBeenCalledWith('/test-path?category=') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=', { scroll: false }) }) /** @@ -345,7 +346,7 @@ describe('useTabSearchParams', () => { setActiveTab('settings') }) - expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings') + expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings', { scroll: false }) // Restore mock ;(usePathname as jest.Mock).mockReturnValue(mockPathname) @@ -400,7 +401,7 @@ describe('useTabSearchParams', () => { }) expect(result.current[0]).toBe('settings') - expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false }) // Change to profile tab act(() => { @@ -409,7 +410,7 @@ describe('useTabSearchParams', () => { }) expect(result.current[0]).toBe('profile') - expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile', { scroll: false }) // Verify push was called twice expect(mockPush).toHaveBeenCalledTimes(2) @@ -431,7 +432,7 @@ describe('useTabSearchParams', () => { setActiveTab('advanced') }) - expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced') + expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced', { scroll: false }) // Restore mock ;(usePathname as jest.Mock).mockReturnValue(mockPathname) diff --git a/web/hooks/use-tab-searchparams.ts b/web/hooks/use-tab-searchparams.ts index 444944f812..427da16eef 100644 --- a/web/hooks/use-tab-searchparams.ts +++ b/web/hooks/use-tab-searchparams.ts @@ -40,7 +40,7 @@ export const useTabSearchParams = ({ setTab(newActiveTab) if (disableSearchParams) return - router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`) + router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`, { scroll: false }) } return [activeTab, setActiveTab] as const diff --git a/web/package.json b/web/package.json index 1103f94850..a646b26bab 100644 --- a/web/package.json +++ b/web/package.json @@ -56,7 +56,7 @@ "@lexical/list": "^0.36.2", "@lexical/react": "^0.36.2", "@lexical/selection": "^0.37.0", - "@lexical/text": "^0.36.2", + "@lexical/text": "^0.38.2", "@lexical/utils": "^0.37.0", "@monaco-editor/react": "^4.7.0", "@octokit/core": "^6.1.6", @@ -79,7 +79,7 @@ "decimal.js": "^10.6.0", "dompurify": "^3.3.0", "echarts": "^5.6.0", - "echarts-for-react": "^3.0.2", + "echarts-for-react": "^3.0.5", "elkjs": "^0.9.3", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", @@ -141,7 +141,7 @@ "uuid": "^10.0.0", "zod": "^3.25.76", "zundo": "^2.3.0", - "zustand": "^4.5.7" + "zustand": "^5.0.9" }, "devDependencies": { "@antfu/eslint-config": "^5.4.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1df1c29aa9..d65fb5e4f3 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -94,8 +94,8 @@ importers: specifier: ^0.37.0 version: 0.37.0 '@lexical/text': - specifier: ^0.36.2 - version: 0.36.2 + specifier: ^0.38.2 + version: 0.38.2 '@lexical/utils': specifier: ^0.37.0 version: 0.37.0 @@ -163,8 +163,8 @@ importers: specifier: ^5.6.0 version: 5.6.0 echarts-for-react: - specifier: ^3.0.2 - version: 3.0.2(echarts@5.6.0)(react@19.1.1) + specifier: ^3.0.5 + version: 3.0.5(echarts@5.6.0)(react@19.1.1) elkjs: specifier: ^0.9.3 version: 0.9.3 @@ -347,10 +347,10 @@ importers: version: 3.25.76 zundo: specifier: ^2.3.0 - version: 2.3.0(zustand@4.5.7(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)) + version: 2.3.0(zustand@5.0.9(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1))) zustand: - specifier: ^4.5.7 - version: 4.5.7(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1) + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1)) devDependencies: '@antfu/eslint-config': specifier: ^5.4.1 @@ -2087,6 +2087,9 @@ packages: '@lexical/text@0.36.2': resolution: {integrity: sha512-IbbqgRdMAD6Uk9b2+qSVoy+8RVcczrz6OgXvg39+EYD+XEC7Rbw7kDTWzuNSJJpP7vxSO8YDZSaIlP5gNH3qKA==} + '@lexical/text@0.38.2': + resolution: {integrity: sha512-+juZxUugtC4T37aE3P0l4I9tsWbogDUnTI/mgYk4Ht9g+gLJnhQkzSA8chIyfTxbj5i0A8yWrUUSw+/xA7lKUQ==} + '@lexical/utils@0.36.2': resolution: {integrity: sha512-P9+t2Ob10YNGYT/PWEER+1EqH8SAjCNRn+7SBvKbr0IdleGF2JvzbJwAWaRwZs1c18P11XdQZ779dGvWlfwBIw==} @@ -4586,10 +4589,10 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - echarts-for-react@3.0.2: - resolution: {integrity: sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==} + echarts-for-react@3.0.5: + resolution: {integrity: sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg==} peerDependencies: - echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 + echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 react: ^15.0.0 || >=16.0.0 echarts@5.6.0: @@ -8445,6 +8448,24 @@ packages: react: optional: true + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': ~19.1.17 + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -10369,6 +10390,10 @@ snapshots: dependencies: lexical: 0.37.0 + '@lexical/text@0.38.2': + dependencies: + lexical: 0.37.0 + '@lexical/utils@0.36.2': dependencies: '@lexical/list': 0.36.2 @@ -13098,7 +13123,7 @@ snapshots: duplexer@0.1.2: {} - echarts-for-react@3.0.2(echarts@5.6.0)(react@19.1.1): + echarts-for-react@3.0.5(echarts@5.6.0)(react@19.1.1): dependencies: echarts: 5.6.0 fast-deep-equal: 3.1.3 @@ -17931,9 +17956,9 @@ snapshots: dependencies: tslib: 2.3.0 - zundo@2.3.0(zustand@4.5.7(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)): + zundo@2.3.0(zustand@5.0.9(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1))): dependencies: - zustand: 4.5.7(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1) + zustand: 5.0.9(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1)) zustand@4.5.7(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1): dependencies: @@ -17943,4 +17968,11 @@ snapshots: immer: 10.1.3 react: 19.1.1 + zustand@5.0.9(@types/react@19.1.17)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1)): + optionalDependencies: + '@types/react': 19.1.17 + immer: 10.1.3 + react: 19.1.1 + use-sync-external-store: 1.6.0(react@19.1.1) + zwitch@2.0.4: {}