mirror of
https://github.com/langgenius/dify.git
synced 2026-01-29 00:06:00 +08:00
Compare commits
8 Commits
fix/workfl
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c29b67e22 | |||
| c080c48aba | |||
| d13638f6e4 | |||
| b4eef76c14 | |||
| cbf7f646d9 | |||
| c58647d39c | |||
| f6be9cd90d | |||
| 360f3bb32f |
@ -36,6 +36,16 @@ class NotionEstimatePayload(BaseModel):
|
||||
doc_language: str = Field(default="English")
|
||||
|
||||
|
||||
class DataSourceNotionListQuery(BaseModel):
|
||||
dataset_id: str | None = Field(default=None, description="Dataset ID")
|
||||
credential_id: str = Field(..., description="Credential ID", min_length=1)
|
||||
datasource_parameters: dict[str, Any] | None = Field(default=None, description="Datasource parameters JSON string")
|
||||
|
||||
|
||||
class DataSourceNotionPreviewQuery(BaseModel):
|
||||
credential_id: str = Field(..., description="Credential ID", min_length=1)
|
||||
|
||||
|
||||
register_schema_model(console_ns, NotionEstimatePayload)
|
||||
|
||||
|
||||
@ -136,26 +146,15 @@ class DataSourceNotionListApi(Resource):
|
||||
def get(self):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
dataset_id = request.args.get("dataset_id", default=None, type=str)
|
||||
credential_id = request.args.get("credential_id", default=None, type=str)
|
||||
if not credential_id:
|
||||
raise ValueError("Credential id is required.")
|
||||
query = DataSourceNotionListQuery.model_validate(request.args.to_dict())
|
||||
|
||||
# Get datasource_parameters from query string (optional, for GitHub and other datasources)
|
||||
datasource_parameters_str = request.args.get("datasource_parameters", default=None, type=str)
|
||||
datasource_parameters = {}
|
||||
if datasource_parameters_str:
|
||||
try:
|
||||
datasource_parameters = json.loads(datasource_parameters_str)
|
||||
if not isinstance(datasource_parameters, dict):
|
||||
raise ValueError("datasource_parameters must be a JSON object.")
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Invalid datasource_parameters JSON format.")
|
||||
datasource_parameters = query.datasource_parameters or {}
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=credential_id,
|
||||
credential_id=query.credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
@ -164,8 +163,8 @@ class DataSourceNotionListApi(Resource):
|
||||
exist_page_ids = []
|
||||
with Session(db.engine) as session:
|
||||
# import notion in the exist dataset
|
||||
if dataset_id:
|
||||
dataset = DatasetService.get_dataset(dataset_id)
|
||||
if query.dataset_id:
|
||||
dataset = DatasetService.get_dataset(query.dataset_id)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
if dataset.data_source_type != "notion_import":
|
||||
@ -173,7 +172,7 @@ class DataSourceNotionListApi(Resource):
|
||||
|
||||
documents = session.scalars(
|
||||
select(Document).filter_by(
|
||||
dataset_id=dataset_id,
|
||||
dataset_id=query.dataset_id,
|
||||
tenant_id=current_tenant_id,
|
||||
data_source_type="notion_import",
|
||||
enabled=True,
|
||||
@ -240,13 +239,12 @@ class DataSourceNotionApi(Resource):
|
||||
def get(self, page_id, page_type):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
credential_id = request.args.get("credential_id", default=None, type=str)
|
||||
if not credential_id:
|
||||
raise ValueError("Credential id is required.")
|
||||
query = DataSourceNotionPreviewQuery.model_validate(request.args.to_dict())
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=credential_id,
|
||||
credential_id=query.credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
|
||||
@ -176,7 +176,18 @@ class IndexingEstimatePayload(BaseModel):
|
||||
return result
|
||||
|
||||
|
||||
register_schema_models(console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload)
|
||||
class ConsoleDatasetListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
include_all: bool = Field(default=False, description="Include all datasets")
|
||||
ids: list[str] = Field(default_factory=list, description="Filter by dataset IDs")
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload, ConsoleDatasetListQuery
|
||||
)
|
||||
|
||||
|
||||
def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool = False) -> dict[str, list[str]]:
|
||||
@ -275,18 +286,19 @@ class DatasetListApi(Resource):
|
||||
@enterprise_license_required
|
||||
def get(self):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
ids = request.args.getlist("ids")
|
||||
query = ConsoleDatasetListQuery.model_validate(request.args.to_dict(flat=False))
|
||||
# provider = request.args.get("provider", default="vendor")
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
tag_ids = request.args.getlist("tag_ids")
|
||||
include_all = request.args.get("include_all", default="false").lower() == "true"
|
||||
if ids:
|
||||
datasets, total = DatasetService.get_datasets_by_ids(ids, current_tenant_id)
|
||||
if query.ids:
|
||||
datasets, total = DatasetService.get_datasets_by_ids(query.ids, current_tenant_id)
|
||||
else:
|
||||
datasets, total = DatasetService.get_datasets(
|
||||
page, limit, current_tenant_id, current_user, search, tag_ids, include_all
|
||||
query.page,
|
||||
query.limit,
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
query.keyword,
|
||||
query.tag_ids,
|
||||
query.include_all,
|
||||
)
|
||||
|
||||
# check embedding setting
|
||||
@ -318,7 +330,13 @@ class DatasetListApi(Resource):
|
||||
else:
|
||||
item.update({"partial_member_list": []})
|
||||
|
||||
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
|
||||
response = {
|
||||
"data": data,
|
||||
"has_more": len(datasets) == query.limit,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"page": query.page,
|
||||
}
|
||||
return response, 200
|
||||
|
||||
@console_ns.doc("create_dataset")
|
||||
|
||||
@ -98,12 +98,19 @@ class BedrockRetrievalPayload(BaseModel):
|
||||
knowledge_id: str
|
||||
|
||||
|
||||
class ExternalApiTemplateListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ExternalKnowledgeApiPayload,
|
||||
ExternalDatasetCreatePayload,
|
||||
ExternalHitTestingPayload,
|
||||
BedrockRetrievalPayload,
|
||||
ExternalApiTemplateListQuery,
|
||||
)
|
||||
|
||||
|
||||
@ -124,19 +131,17 @@ class ExternalApiTemplateListApi(Resource):
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
query = ExternalApiTemplateListQuery.model_validate(request.args.to_dict())
|
||||
|
||||
external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis(
|
||||
page, limit, current_tenant_id, search
|
||||
query.page, query.limit, current_tenant_id, query.keyword
|
||||
)
|
||||
response = {
|
||||
"data": [item.to_dict() for item in external_knowledge_apis],
|
||||
"has_more": len(external_knowledge_apis) == limit,
|
||||
"limit": limit,
|
||||
"has_more": len(external_knowledge_apis) == query.limit,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page": query.page,
|
||||
}
|
||||
return response, 200
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource, marshal_with
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, select
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
@ -28,6 +28,10 @@ class InstalledAppUpdatePayload(BaseModel):
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
class InstalledAppsListQuery(BaseModel):
|
||||
app_id: str | None = Field(default=None, description="App ID to filter by")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -37,13 +41,13 @@ class InstalledAppsListApi(Resource):
|
||||
@account_initialization_required
|
||||
@marshal_with(installed_app_list_fields)
|
||||
def get(self):
|
||||
app_id = request.args.get("app_id", default=None, type=str)
|
||||
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
if app_id:
|
||||
if query.app_id:
|
||||
installed_apps = db.session.scalars(
|
||||
select(InstalledApp).where(
|
||||
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == app_id)
|
||||
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == query.app_id)
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
|
||||
@ -40,6 +40,7 @@ register_schema_models(
|
||||
TagBasePayload,
|
||||
TagBindingPayload,
|
||||
TagBindingRemovePayload,
|
||||
TagListQueryParam,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -87,6 +87,14 @@ class TagUnbindingPayload(BaseModel):
|
||||
target_id: str
|
||||
|
||||
|
||||
class DatasetListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
include_all: bool = Field(default=False, description="Include all datasets")
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
service_api_ns,
|
||||
DatasetCreatePayload,
|
||||
@ -96,6 +104,7 @@ register_schema_models(
|
||||
TagDeletePayload,
|
||||
TagBindingPayload,
|
||||
TagUnbindingPayload,
|
||||
DatasetListQuery,
|
||||
)
|
||||
|
||||
|
||||
@ -113,15 +122,11 @@ class DatasetListApi(DatasetApiResource):
|
||||
)
|
||||
def get(self, tenant_id):
|
||||
"""Resource for getting datasets."""
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
query = DatasetListQuery.model_validate(request.args.to_dict(flat=False))
|
||||
# provider = request.args.get("provider", default="vendor")
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
tag_ids = request.args.getlist("tag_ids")
|
||||
include_all = request.args.get("include_all", default="false").lower() == "true"
|
||||
|
||||
datasets, total = DatasetService.get_datasets(
|
||||
page, limit, tenant_id, current_user, search, tag_ids, include_all
|
||||
query.page, query.limit, tenant_id, current_user, query.keyword, query.tag_ids, query.include_all
|
||||
)
|
||||
# check embedding setting
|
||||
provider_manager = ProviderManager()
|
||||
@ -147,7 +152,13 @@ class DatasetListApi(DatasetApiResource):
|
||||
item["embedding_available"] = False
|
||||
else:
|
||||
item["embedding_available"] = True
|
||||
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
|
||||
response = {
|
||||
"data": data,
|
||||
"has_more": len(datasets) == query.limit,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"page": query.page,
|
||||
}
|
||||
return response, 200
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[DatasetCreatePayload.__name__])
|
||||
|
||||
@ -69,7 +69,14 @@ class DocumentTextUpdate(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate]:
|
||||
class DocumentListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
status: str | None = Field(default=None, description="Document status filter")
|
||||
|
||||
|
||||
for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate, DocumentListQuery]:
|
||||
service_api_ns.schema_model(m.__name__, m.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) # type: ignore
|
||||
|
||||
|
||||
@ -460,34 +467,33 @@ class DocumentListApi(DatasetApiResource):
|
||||
def get(self, tenant_id, dataset_id):
|
||||
dataset_id = str(dataset_id)
|
||||
tenant_id = str(tenant_id)
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
status = request.args.get("status", default=None, type=str)
|
||||
query_params = DocumentListQuery.model_validate(request.args.to_dict())
|
||||
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=tenant_id)
|
||||
|
||||
if status:
|
||||
query = DocumentService.apply_display_status_filter(query, status)
|
||||
if query_params.status:
|
||||
query = DocumentService.apply_display_status_filter(query, query_params.status)
|
||||
|
||||
if search:
|
||||
search = f"%{search}%"
|
||||
if query_params.keyword:
|
||||
search = f"%{query_params.keyword}%"
|
||||
query = query.where(Document.name.like(search))
|
||||
|
||||
query = query.order_by(desc(Document.created_at), desc(Document.position))
|
||||
|
||||
paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
|
||||
paginated_documents = db.paginate(
|
||||
select=query, page=query_params.page, per_page=query_params.limit, max_per_page=100, error_out=False
|
||||
)
|
||||
documents = paginated_documents.items
|
||||
|
||||
response = {
|
||||
"data": marshal(documents, document_fields),
|
||||
"has_more": len(documents) == limit,
|
||||
"limit": limit,
|
||||
"has_more": len(documents) == query_params.limit,
|
||||
"limit": query_params.limit,
|
||||
"total": paginated_documents.total,
|
||||
"page": page,
|
||||
"page": query_params.page,
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Mapping
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
from core.datasource.__base.datasource_plugin import DatasourcePlugin
|
||||
@ -34,7 +34,7 @@ class OnlineDocumentDatasourcePlugin(DatasourcePlugin):
|
||||
def get_online_document_pages(
|
||||
self,
|
||||
user_id: str,
|
||||
datasource_parameters: Mapping[str, Any],
|
||||
datasource_parameters: dict[str, Any],
|
||||
provider_type: str,
|
||||
) -> Generator[OnlineDocumentPagesMessage, None, None]:
|
||||
manager = PluginDatasourceManager()
|
||||
|
||||
@ -64,7 +64,7 @@ dependencies = [
|
||||
"pandas[excel,output-formatting,performance]~=2.2.2",
|
||||
"psycogreen~=1.0.2",
|
||||
"psycopg2-binary~=2.9.6",
|
||||
"pycryptodome==3.19.1",
|
||||
"pycryptodome==3.23.0",
|
||||
"pydantic~=2.11.4",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.11.0",
|
||||
|
||||
@ -131,7 +131,7 @@ class BillingService:
|
||||
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
|
||||
|
||||
url = f"{cls.base_url}{endpoint}"
|
||||
response = httpx.request(method, url, json=json, params=params, headers=headers)
|
||||
response = httpx.request(method, url, json=json, params=params, headers=headers, follow_redirects=True)
|
||||
if method == "GET" and response.status_code != httpx.codes.OK:
|
||||
raise ValueError("Unable to retrieve billing information. Please try again later or contact support.")
|
||||
if method == "PUT":
|
||||
@ -143,6 +143,9 @@ class BillingService:
|
||||
raise ValueError("Invalid arguments.")
|
||||
if method == "POST" and response.status_code != httpx.codes.OK:
|
||||
raise ValueError(f"Unable to send request to {url}. Please try again later or contact support.")
|
||||
if method == "DELETE" and response.status_code != httpx.codes.OK:
|
||||
logger.error("billing_service: DELETE response: %s %s", response.status_code, response.text)
|
||||
raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.")
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
@ -165,7 +168,7 @@ class BillingService:
|
||||
def delete_account(cls, account_id: str):
|
||||
"""Delete account."""
|
||||
params = {"account_id": account_id}
|
||||
return cls._send_request("DELETE", "/account/", params=params)
|
||||
return cls._send_request("DELETE", "/account", params=params)
|
||||
|
||||
@classmethod
|
||||
def is_email_in_freeze(cls, email: str) -> bool:
|
||||
|
||||
@ -171,22 +171,26 @@ class TestBillingServiceSendRequest:
|
||||
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
|
||||
)
|
||||
def test_delete_request_non_200_with_valid_json(self, mock_httpx_request, mock_billing_config, status_code):
|
||||
"""Test DELETE request with non-200 status code but valid JSON response.
|
||||
"""Test DELETE request with non-200 status code raises ValueError.
|
||||
|
||||
DELETE doesn't check status code, so it returns the error JSON.
|
||||
DELETE now checks status code and raises ValueError for non-200 responses.
|
||||
"""
|
||||
# Arrange
|
||||
error_response = {"detail": "Error message"}
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = status_code
|
||||
mock_response.text = "Error message"
|
||||
mock_response.json.return_value = error_response
|
||||
mock_httpx_request.return_value = mock_response
|
||||
|
||||
# Act
|
||||
result = BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
|
||||
# Assert
|
||||
assert result == error_response
|
||||
# Act & Assert
|
||||
with patch("services.billing_service.logger") as mock_logger:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
assert "Unable to process delete request" in str(exc_info.value)
|
||||
# Verify error logging
|
||||
mock_logger.error.assert_called_once()
|
||||
assert "DELETE response" in str(mock_logger.error.call_args)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
|
||||
@ -210,9 +214,9 @@ class TestBillingServiceSendRequest:
|
||||
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
|
||||
)
|
||||
def test_delete_request_non_200_with_invalid_json(self, mock_httpx_request, mock_billing_config, status_code):
|
||||
"""Test DELETE request with non-200 status code and invalid JSON response raises exception.
|
||||
"""Test DELETE request with non-200 status code raises ValueError before JSON parsing.
|
||||
|
||||
DELETE doesn't check status code, so it calls response.json() which raises JSONDecodeError
|
||||
DELETE now checks status code before calling response.json(), so ValueError is raised
|
||||
when the response cannot be parsed as JSON (e.g., empty response).
|
||||
"""
|
||||
# Arrange
|
||||
@ -223,8 +227,13 @@ class TestBillingServiceSendRequest:
|
||||
mock_httpx_request.return_value = mock_response
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
with patch("services.billing_service.logger") as mock_logger:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
assert "Unable to process delete request" in str(exc_info.value)
|
||||
# Verify error logging
|
||||
mock_logger.error.assert_called_once()
|
||||
assert "DELETE response" in str(mock_logger.error.call_args)
|
||||
|
||||
def test_retry_on_request_error(self, mock_httpx_request, mock_billing_config):
|
||||
"""Test that _send_request retries on httpx.RequestError."""
|
||||
@ -789,7 +798,7 @@ class TestBillingServiceAccountManagement:
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with("DELETE", "/account/", params={"account_id": account_id})
|
||||
mock_send_request.assert_called_once_with("DELETE", "/account", params={"account_id": account_id})
|
||||
|
||||
def test_is_email_in_freeze_true(self, mock_send_request):
|
||||
"""Test checking if email is frozen (returns True)."""
|
||||
|
||||
33
api/uv.lock
generated
33
api/uv.lock
generated
@ -1633,7 +1633,7 @@ requires-dist = [
|
||||
{ name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=2.2.2" },
|
||||
{ name = "psycogreen", specifier = "~=1.0.2" },
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.6" },
|
||||
{ name = "pycryptodome", specifier = "==3.19.1" },
|
||||
{ name = "pycryptodome", specifier = "==3.23.0" },
|
||||
{ name = "pydantic", specifier = "~=2.11.4" },
|
||||
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.11.0" },
|
||||
@ -4796,20 +4796,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.19.1"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/38/42a8855ff1bf568c61ca6557e2203f318fb7afeadaf2eb8ecfdbde107151/pycryptodome-3.19.1.tar.gz", hash = "sha256:8ae0dd1bcfada451c35f9e29a3e5db385caabc190f98e4a80ad02a61098fb776", size = 4782144, upload-time = "2023-12-28T06:52:40.741Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/4931bc30674f0de0ca0e827b58c8b0c17313a8eae2754976c610b866118b/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:67939a3adbe637281c611596e44500ff309d547e932c449337649921b17b6297", size = 2417027, upload-time = "2023-12-28T06:51:50.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/e6/238c53267fd8d223029c0a0d3730cb1b6594d60f62e40c4184703dc490b1/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:11ddf6c9b52116b62223b6a9f4741bc4f62bb265392a4463282f7f34bb287180", size = 1579728, upload-time = "2023-12-28T06:51:52.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/87/7181c42c8d5ba89822a4b824830506d0aeec02959bb893614767e3279846/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e6f89480616781d2a7f981472d0cdb09b9da9e8196f43c1234eff45c915766", size = 2051440, upload-time = "2023-12-28T06:51:55.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/dd/332c4c0055527d17dac317ed9f9c864fc047b627d82f4b9a56c110afc6fc/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e1efcb68993b7ce5d1d047a46a601d41281bba9f1971e6be4aa27c69ab8065", size = 2125379, upload-time = "2023-12-28T06:51:58.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9e/320b885ea336c218ff54ec2b276cd70ba6904e4f5a14a771ed39a2c47d59/pycryptodome-3.19.1-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c6273ca5a03b672e504995529b8bae56da0ebb691d8ef141c4aa68f60765700", size = 2153951, upload-time = "2023-12-28T06:52:01.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/54/8ae0c43d1257b41bc9d3277c3f875174fd8ad86b9567f0b8609b99c938ee/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b0bfe61506795877ff974f994397f0c862d037f6f1c0bfc3572195fc00833b96", size = 2044041, upload-time = "2023-12-28T06:52:03.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/93/f8450a92cc38541c3ba1f4cb4e267e15ae6d6678ca617476d52c3a3764d4/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:f34976c5c8eb79e14c7d970fb097482835be8d410a4220f86260695ede4c3e17", size = 2182446, upload-time = "2023-12-28T06:52:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cd/ed6e429fb0792ce368f66e83246264dd3a7a045b0b1e63043ed22a063ce5/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7c9e222d0976f68d0cf6409cfea896676ddc1d98485d601e9508f90f60e2b0a2", size = 2144914, upload-time = "2023-12-28T06:52:07.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/23/b064bd4cfbf2cc5f25afcde0e7c880df5b20798172793137ba4b62d82e72/pycryptodome-3.19.1-cp35-abi3-win32.whl", hash = "sha256:4805e053571140cb37cf153b5c72cd324bb1e3e837cbe590a19f69b6cf85fd03", size = 1713105, upload-time = "2023-12-28T06:52:09.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e0/ded1968a5257ab34216a0f8db7433897a2337d59e6d03be113713b346ea2/pycryptodome-3.19.1-cp35-abi3-win_amd64.whl", hash = "sha256:a470237ee71a1efd63f9becebc0ad84b88ec28e6784a2047684b693f458f41b7", size = 1749222, upload-time = "2023-12-28T06:52:11.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5003,11 +5004,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.6.0"
|
||||
version = "6.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/f4/801632a8b62a805378b6af2b5a3fcbfd8923abf647e0ed1af846a83433b2/pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f", size = 5281063, upload-time = "2026-01-09T11:20:11.786Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ba/96f99276194f720e74ed99905a080f6e77810558874e8935e580331b46de/pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7", size = 328963, upload-time = "2026-01-09T11:20:09.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
91
web/app/components/app/configuration/debug/debug-header.tsx
Normal file
91
web/app/components/app/configuration/debug/debug-header.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ModelAndParameter } from './types'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type DebugHeaderProps = {
|
||||
readonly?: boolean
|
||||
mode: AppModeEnum
|
||||
debugWithMultipleModel: boolean
|
||||
multipleModelConfigs: ModelAndParameter[]
|
||||
varListLength: number
|
||||
expanded: boolean
|
||||
onExpandedChange: (expanded: boolean) => void
|
||||
onClearConversation: () => void
|
||||
onAddModel: () => void
|
||||
}
|
||||
|
||||
const DebugHeader: FC<DebugHeaderProps> = ({
|
||||
readonly,
|
||||
mode,
|
||||
debugWithMultipleModel,
|
||||
multipleModelConfigs,
|
||||
varListLength,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
onClearConversation,
|
||||
onAddModel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
|
||||
<div className="flex items-center">
|
||||
{debugWithMultipleModel && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost-accent"
|
||||
onClick={onAddModel}
|
||||
disabled={multipleModelConfigs.length >= 4}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||
{t('modelProvider.addModel', { ns: 'common' })}
|
||||
(
|
||||
{multipleModelConfigs.length}
|
||||
/4)
|
||||
</Button>
|
||||
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
|
||||
</>
|
||||
)}
|
||||
{mode !== AppModeEnum.COMPLETION && (
|
||||
<>
|
||||
{!readonly && (
|
||||
<TooltipPlus popupContent={t('operation.refresh', { ns: 'common' })}>
|
||||
<ActionButton onClick={onClearConversation}>
|
||||
<RefreshCcw01 className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</TooltipPlus>
|
||||
)}
|
||||
{varListLength > 0 && (
|
||||
<div className="relative ml-1 mr-2">
|
||||
<TooltipPlus popupContent={t('panel.userInputField', { ns: 'workflow' })}>
|
||||
<ActionButton
|
||||
state={expanded ? ActionButtonState.Active : undefined}
|
||||
onClick={() => !readonly && onExpandedChange(!expanded)}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</TooltipPlus>
|
||||
{expanded && (
|
||||
<div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DebugHeader
|
||||
@ -0,0 +1,737 @@
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types'
|
||||
import ChatItem from './chat-item'
|
||||
|
||||
const mockUseAppContext = vi.fn()
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseProviderContext = vi.fn()
|
||||
const mockUseFeatures = vi.fn()
|
||||
const mockUseConfigFromDebugContext = vi.fn()
|
||||
const mockUseFormattingChangedSubscription = vi.fn()
|
||||
const mockUseChat = vi.fn()
|
||||
const mockUseEventEmitterContextContext = vi.fn()
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockUseAppContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/debug-configuration', () => ({
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useConfigFromDebugContext: () => mockUseConfigFromDebugContext(),
|
||||
useFormattingChangedSubscription: (chatList: unknown) => mockUseFormattingChangedSubscription(chatList),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
|
||||
useChat: (...args: unknown[]) => mockUseChat(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
|
||||
}))
|
||||
|
||||
const mockStopChatMessageResponding = vi.fn()
|
||||
const mockFetchConversationMessages = vi.fn()
|
||||
const mockFetchSuggestedQuestions = vi.fn()
|
||||
|
||||
vi.mock('@/service/debug', () => ({
|
||||
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
|
||||
fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
|
||||
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
canFindTool: (collectionId: string, providerId: string) => collectionId === providerId,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/utils', () => ({
|
||||
getLastAnswer: (chatList: { id: string }[]) => chatList.length > 0 ? chatList[chatList.length - 1] : null,
|
||||
}))
|
||||
|
||||
let capturedChatProps: Record<string, unknown> | null = null
|
||||
vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
capturedChatProps = props
|
||||
return <div data-testid="chat-component">Chat</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/avatar', () => ({
|
||||
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
|
||||
}))
|
||||
|
||||
let modelIdCounter = 0
|
||||
|
||||
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||
id: `model-${++modelIdCounter}`,
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'openai',
|
||||
parameters: { temperature: 0.7 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDefaultModelConfig = () => ({
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4',
|
||||
mode: ModelModeType.chat,
|
||||
configs: {
|
||||
prompt_template: 'Hello {{name}}',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string' as const },
|
||||
{ key: 'api-var', name: 'API Var', type: 'api' as const },
|
||||
],
|
||||
},
|
||||
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
opening_statement: '',
|
||||
more_like_this: null,
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: null,
|
||||
speech_to_text: null,
|
||||
text_to_speech: null,
|
||||
file_upload: null,
|
||||
retriever_resource: null,
|
||||
sensitive_word_avoidance: null,
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
dataSets: [],
|
||||
agentConfig: DEFAULT_AGENT_SETTING,
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
image_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const createDefaultFeatures = () => ({
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: true },
|
||||
text2speech: { enabled: false },
|
||||
file: { enabled: true, image: { enabled: true } },
|
||||
suggested: { enabled: true },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
})
|
||||
|
||||
const createTextGenerationModelList = (models: Array<{
|
||||
provider: string
|
||||
model: string
|
||||
features?: string[]
|
||||
mode?: string
|
||||
}> = []) => {
|
||||
const providerMap = new Map<string, { model: string, features: string[], model_properties: { mode: string } }[]>()
|
||||
|
||||
for (const m of models) {
|
||||
if (!providerMap.has(m.provider)) {
|
||||
providerMap.set(m.provider, [])
|
||||
}
|
||||
providerMap.get(m.provider)!.push({
|
||||
model: m.model,
|
||||
features: m.features ?? [],
|
||||
model_properties: { mode: m.mode ?? 'chat' },
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
|
||||
provider,
|
||||
models: modelsList,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('ChatItem', () => {
|
||||
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelIdCounter = 0
|
||||
capturedChatProps = null
|
||||
subscriptionCallback = null
|
||||
|
||||
mockUseAppContext.mockReturnValue({
|
||||
userProfile: { avatar_url: 'avatar.png', name: 'Test User' },
|
||||
})
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
modelConfig: createDefaultModelConfig(),
|
||||
appId: 'test-app-id',
|
||||
inputs: { name: 'World' },
|
||||
collectionList: [],
|
||||
})
|
||||
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-3.5-turbo', features: [ModelFeatureEnum.vision], mode: 'chat' },
|
||||
{ provider: 'openai', model: 'gpt-4', features: [], mode: 'chat' },
|
||||
]),
|
||||
})
|
||||
|
||||
const features = createDefaultFeatures()
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||
|
||||
mockUseConfigFromDebugContext.mockReturnValue({
|
||||
baseConfig: true,
|
||||
})
|
||||
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1', content: 'Hello' }],
|
||||
isResponding: false,
|
||||
handleSend: vi.fn(),
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
mockUseEventEmitterContextContext.mockReturnValue({
|
||||
eventEmitter: {
|
||||
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
|
||||
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
|
||||
subscriptionCallback = callback
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render Chat component when chatList is not empty', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when chatList is empty', () => {
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [],
|
||||
isResponding: false,
|
||||
handleSend: vi.fn(),
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
const { container } = render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should pass correct props to Chat component', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedChatProps!.noChatInput).toBe(true)
|
||||
expect(capturedChatProps!.noStopResponding).toBe(true)
|
||||
expect(capturedChatProps!.showPromptLog).toBe(true)
|
||||
expect(capturedChatProps!.hideLogModal).toBe(true)
|
||||
expect(capturedChatProps!.noSpacing).toBe(true)
|
||||
expect(capturedChatProps!.chatContainerClassName).toBe('p-4')
|
||||
expect(capturedChatProps!.chatFooterClassName).toBe('p-4 pb-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('config building', () => {
|
||||
it('should merge configTemplate with features', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const config = capturedChatProps!.config as ChatConfig & { baseConfig?: boolean }
|
||||
expect(config.baseConfig).toBe(true)
|
||||
expect(config.more_like_this).toEqual({ enabled: false })
|
||||
expect(config.opening_statement).toBe('Hello')
|
||||
expect(config.suggested_questions).toEqual(['Q1'])
|
||||
expect(config.speech_to_text).toEqual({ enabled: true })
|
||||
expect(config.file_upload).toEqual({ enabled: true, image: { enabled: true } })
|
||||
})
|
||||
|
||||
it('should use empty opening_statement when opening is disabled', () => {
|
||||
const features = createDefaultFeatures()
|
||||
features.opening = { enabled: false, opening_statement: 'Hello', suggested_questions: ['Q1'] }
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const config = capturedChatProps!.config as ChatConfig
|
||||
expect(config.opening_statement).toBe('')
|
||||
expect(config.suggested_questions).toEqual([])
|
||||
})
|
||||
|
||||
it('should use empty string fallback when opening_statement is undefined', () => {
|
||||
const features = createDefaultFeatures()
|
||||
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
|
||||
features.opening = { enabled: true, opening_statement: undefined as any, suggested_questions: ['Q1'] }
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const config = capturedChatProps!.config as ChatConfig
|
||||
expect(config.opening_statement).toBe('')
|
||||
})
|
||||
|
||||
it('should use empty array fallback when suggested_questions is undefined', () => {
|
||||
const features = createDefaultFeatures()
|
||||
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
|
||||
features.opening = { enabled: true, opening_statement: 'Hello', suggested_questions: undefined as any }
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const config = capturedChatProps!.config as ChatConfig
|
||||
expect(config.suggested_questions).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle undefined opening feature', () => {
|
||||
const features = createDefaultFeatures()
|
||||
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
|
||||
features.opening = undefined as any
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const config = capturedChatProps!.config as ChatConfig
|
||||
expect(config.opening_statement).toBe('')
|
||||
expect(config.suggested_questions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputsForm transformation', () => {
|
||||
it('should filter out api type variables and map to InputForm', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// The useChat is called with inputsForm
|
||||
const useChatCall = mockUseChat.mock.calls[0]
|
||||
const inputsForm = useChatCall[1].inputsForm
|
||||
|
||||
expect(inputsForm).toHaveLength(1)
|
||||
expect(inputsForm[0]).toEqual(expect.objectContaining({
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('event subscription', () => {
|
||||
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Trigger the event
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test message', files: [{ id: 'file-1' }] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL_RESTART event', async () => {
|
||||
const handleRestart = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend: vi.fn(),
|
||||
suggestedQuestions: [],
|
||||
handleRestart,
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Trigger the event
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleRestart).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('doSend', () => {
|
||||
it('should find current provider and model from textGenerationModelList', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
'apps/test-app-id/chat-messages',
|
||||
expect.objectContaining({
|
||||
query: 'test',
|
||||
inputs: { name: 'World' },
|
||||
model_config: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
provider: 'openai',
|
||||
name: 'gpt-3.5-turbo',
|
||||
mode: 'chat',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should include files when file upload is enabled and vision is supported', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
// gpt-3.5-turbo has vision feature
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const files = [{ id: 'file-1', name: 'image.png' }]
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
files,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not include files when vision is not supported', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
// gpt-4 does not have vision feature
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const files = [{ id: 'file-1', name: 'image.png' }]
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
expect(callArgs.files).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle provider not found in textGenerationModelList', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
// Use a provider that doesn't exist in the list
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'unknown-provider', model: 'unknown-model' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [{ id: 'file-1' }] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
// Files should not be included when provider/model not found (no vision support)
|
||||
expect(callArgs.files).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle model with no features array', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
// Model list where model has no features property
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: [
|
||||
{
|
||||
provider: 'custom',
|
||||
models: [{ model: 'custom-model', model_properties: { mode: 'chat' } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'custom', model: 'custom-model' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [{ id: 'file-1' }] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
// Files should not be included when features is undefined
|
||||
expect(callArgs.files).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle undefined files parameter', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: undefined },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
expect(callArgs.files).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool icons building', () => {
|
||||
it('should build tool icons from agent config', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
modelConfig: {
|
||||
...createDefaultModelConfig(),
|
||||
agentConfig: {
|
||||
tools: [
|
||||
{ tool_name: 'search', provider_id: 'provider-1' },
|
||||
{ tool_name: 'calculator', provider_id: 'provider-2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
appId: 'test-app-id',
|
||||
inputs: {},
|
||||
collectionList: [
|
||||
{ id: 'provider-1', icon: 'search-icon' },
|
||||
{ id: 'provider-2', icon: 'calc-icon' },
|
||||
],
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedChatProps!.allToolIcons).toEqual({
|
||||
search: 'search-icon',
|
||||
calculator: 'calc-icon',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing tools gracefully', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
modelConfig: {
|
||||
...createDefaultModelConfig(),
|
||||
agentConfig: {
|
||||
tools: undefined,
|
||||
},
|
||||
},
|
||||
appId: 'test-app-id',
|
||||
inputs: {},
|
||||
collectionList: [],
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedChatProps!.allToolIcons).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFormattingChangedSubscription', () => {
|
||||
it('should call useFormattingChangedSubscription with chatList', () => {
|
||||
const chatList = [{ id: 'msg-1' }, { id: 'msg-2' }]
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList,
|
||||
isResponding: false,
|
||||
handleSend: vi.fn(),
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(mockUseFormattingChangedSubscription).toHaveBeenCalledWith(chatList)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useChat callbacks', () => {
|
||||
it('should pass stopChatMessageResponding callback to useChat', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Get the stopResponding callback passed to useChat (4th argument)
|
||||
const useChatCall = mockUseChat.mock.calls[0]
|
||||
const stopRespondingCallback = useChatCall[3]
|
||||
|
||||
// Invoke it with a taskId
|
||||
stopRespondingCallback('test-task-id')
|
||||
|
||||
expect(mockStopChatMessageResponding).toHaveBeenCalledWith('test-app-id', 'test-task-id')
|
||||
})
|
||||
|
||||
it('should pass onGetConversationMessages callback to handleSend', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Get the callbacks object (3rd argument to handleSend)
|
||||
const callbacks = handleSend.mock.calls[0][2]
|
||||
|
||||
// Invoke onGetConversationMessages
|
||||
const mockGetAbortController = vi.fn()
|
||||
callbacks.onGetConversationMessages('conv-123', mockGetAbortController)
|
||||
|
||||
expect(mockFetchConversationMessages).toHaveBeenCalledWith('test-app-id', 'conv-123', mockGetAbortController)
|
||||
})
|
||||
|
||||
it('should pass onGetSuggestedQuestions callback to handleSend', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseChat.mockReturnValue({
|
||||
chatList: [{ id: 'msg-1' }],
|
||||
isResponding: false,
|
||||
handleSend,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||
|
||||
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Get the callbacks object (3rd argument to handleSend)
|
||||
const callbacks = handleSend.mock.calls[0][2]
|
||||
|
||||
// Invoke onGetSuggestedQuestions
|
||||
const mockGetAbortController = vi.fn()
|
||||
callbacks.onGetSuggestedQuestions('response-item-123', mockGetAbortController)
|
||||
|
||||
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('test-app-id', 'response-item-123', mockGetAbortController)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,599 @@
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import DebugItem from './debug-item'
|
||||
|
||||
const mockUseTranslation = vi.fn()
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseDebugWithMultipleModelContext = vi.fn()
|
||||
const mockUseProviderContext = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/debug-configuration', () => ({
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
}))
|
||||
|
||||
vi.mock('./context', () => ({
|
||||
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat-item', () => ({
|
||||
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
|
||||
<div data-testid="chat-item" data-model-id={modelAndParameter.id}>ChatItem</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./text-generation-item', () => ({
|
||||
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
|
||||
<div data-testid="text-generation-item" data-model-id={modelAndParameter.id}>TextGenerationItem</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./model-parameter-trigger', () => ({
|
||||
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
|
||||
<div data-testid="model-parameter-trigger" data-model-id={modelAndParameter.id}>ModelParameterTrigger</div>
|
||||
),
|
||||
}))
|
||||
|
||||
type DropdownItem = { value: string, text: string }
|
||||
type DropdownProps = {
|
||||
items?: DropdownItem[]
|
||||
secondItems?: DropdownItem[]
|
||||
onSelect: (item: DropdownItem) => void
|
||||
}
|
||||
let capturedDropdownProps: DropdownProps | null = null
|
||||
vi.mock('@/app/components/base/dropdown', () => ({
|
||||
default: (props: DropdownProps) => {
|
||||
capturedDropdownProps = props
|
||||
return (
|
||||
<div data-testid="dropdown">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-trigger"
|
||||
onClick={() => {
|
||||
// Mock dropdown menu showing items
|
||||
}}
|
||||
>
|
||||
Dropdown
|
||||
</button>
|
||||
{props.items?.map((item: DropdownItem) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
data-testid={`dropdown-item-${item.value}`}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
{props.secondItems?.map((item: DropdownItem) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
data-testid={`dropdown-second-item-${item.value}`}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
let modelIdCounter = 0
|
||||
|
||||
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||
id: `model-${++modelIdCounter}`,
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'openai',
|
||||
parameters: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createTextGenerationModelList = (models: Array<{ provider: string, model: string, status?: ModelStatusEnum }> = []) => {
|
||||
const providerMap = new Map<string, { model: string, status: ModelStatusEnum, model_properties: { mode: string }, features: string[] }[]>()
|
||||
|
||||
for (const m of models) {
|
||||
if (!providerMap.has(m.provider)) {
|
||||
providerMap.set(m.provider, [])
|
||||
}
|
||||
providerMap.get(m.provider)!.push({
|
||||
model: m.model,
|
||||
status: m.status ?? ModelStatusEnum.active,
|
||||
model_properties: { mode: 'chat' },
|
||||
features: [],
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
|
||||
provider,
|
||||
models: modelsList,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('DebugItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelIdCounter = 0
|
||||
capturedDropdownProps = null
|
||||
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => key,
|
||||
})
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render with index number', () => {
|
||||
const modelAndParameter = createModelAndParameter({ id: 'model-a' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByText('#1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correct index for second model', () => {
|
||||
const model1 = createModelAndParameter({ id: 'model-a' })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [model1, model2],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={model2} />)
|
||||
|
||||
expect(screen.getByText('#2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ModelParameterTrigger', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Dropdown', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} className="custom-class" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply custom style', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} style={{ width: '300px' }} />)
|
||||
|
||||
expect(container.firstChild).toHaveStyle({ width: '300px' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatItem rendering', () => {
|
||||
it('should render ChatItem in CHAT mode with active model', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChatItem in AGENT_CHAT mode with active model', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ChatItem when model is not active', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ChatItem when provider not found', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'unknown', model: 'model' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextGenerationItem rendering', () => {
|
||||
it('should render TextGenerationItem in COMPLETION mode with active model', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TextGenerationItem when model is not active', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TextGenerationItem in CHAT mode', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||
]),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown menu items', () => {
|
||||
it('should show duplicate option when less than 4 models', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.items).toContainEqual(
|
||||
expect.objectContaining({ value: 'duplicate' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide duplicate option when 4 or more models', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [
|
||||
modelAndParameter,
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter(),
|
||||
],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.items).not.toContainEqual(
|
||||
expect.objectContaining({ value: 'duplicate' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show debug-as-single-model when provider and model are set', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.items).toContainEqual(
|
||||
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide debug-as-single-model when provider is missing', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: '', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.items).not.toContainEqual(
|
||||
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide debug-as-single-model when model is missing', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: '' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.items).not.toContainEqual(
|
||||
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show remove option when more than 2 models', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter, createModelAndParameter(), createModelAndParameter()],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.secondItems).toContainEqual(
|
||||
expect.objectContaining({ value: 'remove' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide remove option when 2 or fewer models', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedDropdownProps!.secondItems).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown actions', () => {
|
||||
it('should duplicate model when clicking duplicate', () => {
|
||||
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter, model2],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'model-a' }),
|
||||
expect.objectContaining({ provider: 'openai', model: 'gpt-4' }),
|
||||
expect.objectContaining({ id: 'model-b' }),
|
||||
]),
|
||||
)
|
||||
expect(onMultipleModelConfigsChange.mock.calls[0][1]).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should not duplicate when already at 4 models', () => {
|
||||
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [
|
||||
modelAndParameter,
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter(),
|
||||
],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Duplicate option should not be rendered when at 4 models
|
||||
expect(screen.queryByTestId('dropdown-item-duplicate')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should early return when trying to duplicate with 4 models via handleSelect', () => {
|
||||
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [
|
||||
modelAndParameter,
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter(),
|
||||
createModelAndParameter(),
|
||||
],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Directly call handleSelect with duplicate action to cover line 42
|
||||
capturedDropdownProps!.onSelect({ value: 'duplicate', text: 'Duplicate' })
|
||||
|
||||
// Should not call onMultipleModelConfigsChange due to early return
|
||||
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDebugWithMultipleModelChange when clicking debug-as-single-model', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
const onDebugWithMultipleModelChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange,
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
|
||||
|
||||
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
|
||||
})
|
||||
|
||||
it('should remove model when clicking remove', () => {
|
||||
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
const model3 = createModelAndParameter({ id: 'model-c' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter, model2, model3],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
[
|
||||
expect.objectContaining({ id: 'model-b' }),
|
||||
expect.objectContaining({ id: 'model-c' }),
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DebugWithMultipleModelContextType } from './context'
|
||||
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
||||
import type { EnableType } from '@/app/components/base/chat/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import {
|
||||
memo,
|
||||
@ -40,13 +41,7 @@ const DebugWithMultipleModel = () => {
|
||||
if (checkCanSend && !checkCanSend())
|
||||
return
|
||||
|
||||
eventEmitter?.emit({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: {
|
||||
message,
|
||||
files,
|
||||
},
|
||||
} as any)
|
||||
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message, files } } as any) // eslint-disable-line ts/no-explicit-any
|
||||
}, [eventEmitter, checkCanSend])
|
||||
|
||||
const twoLine = multipleModelConfigs.length === 2
|
||||
@ -147,7 +142,7 @@ const DebugWithMultipleModel = () => {
|
||||
showFileUpload={false}
|
||||
onFeatureBarClick={setShowAppConfigureFeaturesModal}
|
||||
onSend={handleSend}
|
||||
speechToTextConfig={speech2text as any}
|
||||
speechToTextConfig={speech2text as EnableType}
|
||||
visionConfig={file}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
|
||||
@ -0,0 +1,436 @@
|
||||
import type * as React from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ModelParameterTrigger from './model-parameter-trigger'
|
||||
|
||||
// Mock MODEL_STATUS_TEXT that is imported in the component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', async (importOriginal) => {
|
||||
const original = await importOriginal() as object
|
||||
return {
|
||||
...original,
|
||||
MODEL_STATUS_TEXT: {
|
||||
'disabled': { en_US: 'Disabled', zh_Hans: '已禁用' },
|
||||
'quota-exceeded': { en_US: 'Quota Exceeded', zh_Hans: '配额已用完' },
|
||||
'no-configure': { en_US: 'No Configure', zh_Hans: '未配置凭据' },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseTranslation = vi.fn()
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseDebugWithMultipleModelContext = vi.fn()
|
||||
const mockUseLanguage = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/debug-configuration', () => ({
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
}))
|
||||
|
||||
vi.mock('./context', () => ({
|
||||
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => mockUseLanguage(),
|
||||
}))
|
||||
|
||||
type RenderTriggerParams = {
|
||||
open: boolean
|
||||
currentProvider: { provider: string, icon: string } | null
|
||||
currentModel: { model: string, status: ModelStatusEnum } | null
|
||||
}
|
||||
type ModalProps = {
|
||||
provider: string
|
||||
modelId: string
|
||||
isAdvancedMode: boolean
|
||||
completionParams: Record<string, unknown>
|
||||
debugWithMultipleModel: boolean
|
||||
setModel: (model: { modelId: string, provider: string }) => void
|
||||
onCompletionParamsChange: (params: Record<string, unknown>) => void
|
||||
onDebugWithMultipleModelChange: () => void
|
||||
renderTrigger: (params: RenderTriggerParams) => React.ReactElement
|
||||
}
|
||||
let capturedModalProps: ModalProps | null = null
|
||||
let mockRenderTriggerFn: ((params: RenderTriggerParams) => React.ReactElement) | null = null
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: (props: ModalProps) => {
|
||||
capturedModalProps = props
|
||||
mockRenderTriggerFn = props.renderTrigger
|
||||
|
||||
// Render the trigger with some mock data
|
||||
const triggerElement = props.renderTrigger({
|
||||
open: false,
|
||||
currentProvider: props.provider
|
||||
? { provider: props.provider, icon: 'provider-icon' }
|
||||
: null,
|
||||
currentModel: props.modelId
|
||||
? { model: props.modelId, status: ModelStatusEnum.active }
|
||||
: null,
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-testid="model-parameter-modal">
|
||||
{triggerElement}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-model-btn"
|
||||
onClick={() => props.setModel({ modelId: 'new-model', provider: 'new-provider' })}
|
||||
>
|
||||
Select Model
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="change-params-btn"
|
||||
onClick={() => props.onCompletionParamsChange({ temperature: 0.9 })}
|
||||
>
|
||||
Change Params
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="debug-single-btn"
|
||||
onClick={() => props.onDebugWithMultipleModelChange()}
|
||||
>
|
||||
Debug Single
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
|
||||
default: ({ provider, modelName }: { provider: { provider: string } | null, modelName?: string }) => (
|
||||
<div data-testid="model-icon" data-provider={provider?.provider} data-model={modelName}>
|
||||
ModelIcon
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: { model: string } | null }) => (
|
||||
<div data-testid="model-name" data-model={modelItem?.model}>
|
||||
{modelItem?.model}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/shapes', () => ({
|
||||
CubeOutline: () => <div data-testid="cube-icon">CubeOutline</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback', () => ({
|
||||
AlertTriangle: () => <div data-testid="alert-icon">AlertTriangle</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
|
||||
}))
|
||||
|
||||
let modelIdCounter = 0
|
||||
|
||||
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||
id: `model-${++modelIdCounter}`,
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'openai',
|
||||
parameters: { temperature: 0.7 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ModelParameterTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelIdCounter = 0
|
||||
capturedModalProps = null
|
||||
mockRenderTriggerFn = null
|
||||
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => key,
|
||||
})
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
})
|
||||
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
mockUseLanguage.mockReturnValue('en_US')
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render ModelParameterModal with correct props', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
|
||||
expect(capturedModalProps!.isAdvancedMode).toBe(false)
|
||||
expect(capturedModalProps!.provider).toBe('openai')
|
||||
expect(capturedModalProps!.modelId).toBe('gpt-4')
|
||||
expect(capturedModalProps!.completionParams).toEqual({ temperature: 0.7 })
|
||||
expect(capturedModalProps!.debugWithMultipleModel).toBe(true)
|
||||
})
|
||||
|
||||
it('should pass isAdvancedMode from context', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: true,
|
||||
})
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedModalProps!.isAdvancedMode).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger rendering', () => {
|
||||
it('should render model icon when provider exists', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cube icon when no provider', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('cube-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model name when model exists', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('model-name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select model text when no model', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByText('modelProvider.selectModel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSelectModel', () => {
|
||||
it('should update model and provider in configs', () => {
|
||||
const model1 = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-3.5' })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [model1, model2],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={model1} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-model-btn'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
[
|
||||
expect.objectContaining({ id: 'model-a', model: 'new-model', provider: 'new-provider' }),
|
||||
expect.objectContaining({ id: 'model-b' }),
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('should update correct model when multiple configs exist', () => {
|
||||
const model1 = createModelAndParameter({ id: 'model-a' })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
const model3 = createModelAndParameter({ id: 'model-c' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [model1, model2, model3],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={model2} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-model-btn'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
[
|
||||
expect.objectContaining({ id: 'model-a' }),
|
||||
expect.objectContaining({ id: 'model-b', model: 'new-model', provider: 'new-provider' }),
|
||||
expect.objectContaining({ id: 'model-c' }),
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleParamsChange', () => {
|
||||
it('should update parameters in configs', () => {
|
||||
const model1 = createModelAndParameter({ id: 'model-a', parameters: { temperature: 0.5 } })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [model1, model2],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={model1} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('change-params-btn'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
[
|
||||
expect.objectContaining({ id: 'model-a', parameters: { temperature: 0.9 } }),
|
||||
expect.objectContaining({ id: 'model-b' }),
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onDebugWithMultipleModelChange', () => {
|
||||
it('should call onDebugWithMultipleModelChange with current modelAndParameter', () => {
|
||||
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||
const onDebugWithMultipleModelChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange,
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('debug-single-btn'))
|
||||
|
||||
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('index finding', () => {
|
||||
it('should find correct index for model in middle of array', () => {
|
||||
const model1 = createModelAndParameter({ id: 'model-a' })
|
||||
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||
const model3 = createModelAndParameter({ id: 'model-c' })
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [model1, model2, model3],
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={model2} />)
|
||||
|
||||
// Verify that the correct index is used by checking the result of handleSelectModel
|
||||
fireEvent.click(screen.getByTestId('select-model-btn'))
|
||||
|
||||
// The second model (index 1) should be updated
|
||||
const updatedConfigs = onMultipleModelConfigsChange.mock.calls[0][1]
|
||||
expect(updatedConfigs[0].id).toBe('model-a')
|
||||
expect(updatedConfigs[1].model).toBe('new-model') // This one should be updated
|
||||
expect(updatedConfigs[2].id).toBe('model-c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTrigger styling and states', () => {
|
||||
it('should render trigger with open state styling', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Call renderTrigger with open=true to test the open styling branch
|
||||
const triggerWithOpen = mockRenderTriggerFn!({
|
||||
open: true,
|
||||
currentProvider: { provider: 'openai', icon: 'provider-icon' },
|
||||
currentModel: { model: 'gpt-4', status: ModelStatusEnum.active },
|
||||
})
|
||||
|
||||
expect(triggerWithOpen).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render warning tooltip when model status is not active', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Call renderTrigger with inactive model status to test the warning branch
|
||||
const triggerWithInactiveModel = mockRenderTriggerFn!({
|
||||
open: false,
|
||||
currentProvider: { provider: 'openai', icon: 'provider-icon' },
|
||||
currentModel: { model: 'gpt-4', status: ModelStatusEnum.disabled },
|
||||
})
|
||||
|
||||
expect(triggerWithInactiveModel).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render warning background and tooltip for inactive model', () => {
|
||||
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [modelAndParameter],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// Test with quota_exceeded status (another inactive status)
|
||||
const triggerWithQuotaExceeded = mockRenderTriggerFn!({
|
||||
open: false,
|
||||
currentProvider: { provider: 'openai', icon: 'provider-icon' },
|
||||
currentModel: { model: 'gpt-4', status: ModelStatusEnum.quotaExceeded },
|
||||
})
|
||||
|
||||
expect(triggerWithQuotaExceeded).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,621 @@
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/app/components/base/chat/types'
|
||||
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
|
||||
import TextGenerationItem from './text-generation-item'
|
||||
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseProviderContext = vi.fn()
|
||||
const mockUseFeatures = vi.fn()
|
||||
const mockUseTextGeneration = vi.fn()
|
||||
const mockUseEventEmitterContextContext = vi.fn()
|
||||
const mockPromptVariablesToUserInputsForm = vi.fn()
|
||||
|
||||
vi.mock('@/context/debug-configuration', () => ({
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/text-generation/hooks', () => ({
|
||||
useTextGeneration: () => mockUseTextGeneration(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/model-config', () => ({
|
||||
promptVariablesToUserInputsForm: (vars: unknown) => mockPromptVariablesToUserInputsForm(vars),
|
||||
}))
|
||||
|
||||
let capturedTextGenerationProps: Record<string, unknown> | null = null
|
||||
vi.mock('@/app/components/app/text-generate/item', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
capturedTextGenerationProps = props
|
||||
return <div data-testid="text-generation-component">TextGeneration</div>
|
||||
},
|
||||
}))
|
||||
|
||||
let modelIdCounter = 0
|
||||
|
||||
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||
id: `model-${++modelIdCounter}`,
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'openai',
|
||||
parameters: { temperature: 0.7 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDefaultModelConfig = () => ({
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4',
|
||||
mode: ModelModeType.completion,
|
||||
configs: {
|
||||
prompt_template: 'Hello {{name}}',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
|
||||
{ key: 'context', name: 'Context', type: 'string' as const, is_context_var: true },
|
||||
],
|
||||
},
|
||||
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
opening_statement: '',
|
||||
more_like_this: null,
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: null,
|
||||
speech_to_text: null,
|
||||
text_to_speech: null,
|
||||
file_upload: null,
|
||||
retriever_resource: null,
|
||||
sensitive_word_avoidance: null,
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
dataSets: [],
|
||||
agentConfig: DEFAULT_AGENT_SETTING,
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
image_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const createDefaultFeatures = () => ({
|
||||
moreLikeThis: { enabled: true },
|
||||
moderation: { enabled: false },
|
||||
text2speech: { enabled: true },
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const createTextGenerationModelList = (models: Array<{
|
||||
provider: string
|
||||
model: string
|
||||
mode?: string
|
||||
}> = []) => {
|
||||
const providerMap = new Map<string, { model: string, model_properties: { mode: string } }[]>()
|
||||
|
||||
for (const m of models) {
|
||||
if (!providerMap.has(m.provider)) {
|
||||
providerMap.set(m.provider, [])
|
||||
}
|
||||
providerMap.get(m.provider)!.push({
|
||||
model: m.model,
|
||||
model_properties: { mode: m.mode ?? 'completion' },
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
|
||||
provider,
|
||||
models: modelsList,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('TextGenerationItem', () => {
|
||||
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelIdCounter = 0
|
||||
capturedTextGenerationProps = null
|
||||
subscriptionCallback = null
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: createDefaultModelConfig(),
|
||||
appId: 'test-app-id',
|
||||
inputs: { name: 'World' },
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: { enabled: true },
|
||||
introduction: 'Welcome',
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
citationConfig: { enabled: false },
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
dataSets: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||
datasetConfigs: { retrieval_model: 'single' },
|
||||
})
|
||||
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: createTextGenerationModelList([
|
||||
{ provider: 'openai', model: 'gpt-3.5-turbo', mode: 'completion' },
|
||||
{ provider: 'openai', model: 'gpt-4', mode: 'completion' },
|
||||
]),
|
||||
})
|
||||
|
||||
const features = createDefaultFeatures()
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'Generated text',
|
||||
handleSend: vi.fn(),
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
mockUseEventEmitterContextContext.mockReturnValue({
|
||||
eventEmitter: {
|
||||
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
|
||||
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
|
||||
subscriptionCallback = callback
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mockPromptVariablesToUserInputsForm.mockReturnValue([
|
||||
{ key: 'name', label: 'Name', variable: 'name' },
|
||||
])
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render TextGeneration component', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct props to TextGeneration component', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedTextGenerationProps!.content).toBe('Generated text')
|
||||
expect(capturedTextGenerationProps!.isLoading).toBe(false)
|
||||
expect(capturedTextGenerationProps!.isResponding).toBe(false)
|
||||
expect(capturedTextGenerationProps!.messageId).toBe('msg-1')
|
||||
expect(capturedTextGenerationProps!.isError).toBe(false)
|
||||
expect(capturedTextGenerationProps!.inSidePanel).toBe(true)
|
||||
expect(capturedTextGenerationProps!.siteInfo).toBeNull()
|
||||
})
|
||||
|
||||
it('should show loading state when no completion and is responding', () => {
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend: vi.fn(),
|
||||
isResponding: true,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedTextGenerationProps!.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show loading state when has completion', () => {
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'Some text',
|
||||
handleSend: vi.fn(),
|
||||
isResponding: true,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
expect(capturedTextGenerationProps!.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config building', () => {
|
||||
it('should build config with correct pre_prompt in simple mode', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
// The config is built internally, we verify via the handleSend call
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
const handleSend = mockUseTextGeneration().handleSend
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
pre_prompt: 'Hello {{name}}',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use empty pre_prompt in advanced mode', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
...mockUseDebugConfigurationContext(),
|
||||
isAdvancedMode: true,
|
||||
modelConfig: createDefaultModelConfig(),
|
||||
appId: 'test-app-id',
|
||||
inputs: {},
|
||||
promptMode: 'advanced',
|
||||
speechToTextConfig: { enabled: true },
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
citationConfig: { enabled: false },
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
dataSets: [],
|
||||
datasetConfigs: { retrieval_model: 'single' },
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
const handleSend = mockUseTextGeneration().handleSend
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
pre_prompt: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should find context variable from prompt_variables', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
const handleSend = mockUseTextGeneration().handleSend
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
dataset_query_variable: 'context',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use empty string for dataset_query_variable when no context var exists', () => {
|
||||
const modelConfigWithoutContextVar = {
|
||||
...createDefaultModelConfig(),
|
||||
configs: {
|
||||
prompt_template: 'Hello {{name}}',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
|
||||
],
|
||||
},
|
||||
}
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: modelConfigWithoutContextVar,
|
||||
appId: 'test-app-id',
|
||||
inputs: { name: 'World' },
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: { enabled: true },
|
||||
introduction: 'Welcome',
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
citationConfig: { enabled: false },
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
dataSets: [],
|
||||
datasetConfigs: { retrieval_model: 'single' },
|
||||
})
|
||||
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
dataset_query_variable: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('datasets transformation', () => {
|
||||
it('should transform dataSets to postDatasets format', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
...mockUseDebugConfigurationContext(),
|
||||
isAdvancedMode: false,
|
||||
modelConfig: createDefaultModelConfig(),
|
||||
appId: 'test-app-id',
|
||||
inputs: {},
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: { enabled: true },
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
citationConfig: { enabled: false },
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
dataSets: [
|
||||
{ id: 'ds-1', name: 'Dataset 1' },
|
||||
{ id: 'ds-2', name: 'Dataset 2' },
|
||||
],
|
||||
datasetConfigs: { retrieval_model: 'single' },
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
const handleSend = mockUseTextGeneration().handleSend
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
dataset_configs: expect.objectContaining({
|
||||
datasets: {
|
||||
datasets: [
|
||||
{ dataset: { enabled: true, id: 'ds-1' } },
|
||||
{ dataset: { enabled: true, id: 'ds-2' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event subscription', () => {
|
||||
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test message', files: [] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
'apps/test-app-id/completion-messages',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore non-matching events', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: 'SOME_OTHER_EVENT',
|
||||
payload: { message: 'test' },
|
||||
})
|
||||
|
||||
expect(handleSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('doSend', () => {
|
||||
it('should build config data with model info', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({
|
||||
provider: 'openai',
|
||||
model: 'gpt-3.5-turbo',
|
||||
parameters: { temperature: 0.8 },
|
||||
})
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
model: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-3.5-turbo',
|
||||
mode: 'completion',
|
||||
completion_params: { temperature: 0.8 },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should process local files by clearing url', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const files = [
|
||||
{ id: 'file-1', transfer_method: TransferMethod.local_file, url: 'http://example.com/file1' },
|
||||
{ id: 'file-2', transfer_method: TransferMethod.remote_url, url: 'http://example.com/file2' },
|
||||
]
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
expect(callArgs.files[0].url).toBe('')
|
||||
expect(callArgs.files[1].url).toBe('http://example.com/file2')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not include files when file upload is disabled', async () => {
|
||||
const features = { ...createDefaultFeatures(), file: { enabled: false } }
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: typeof features }) => unknown) => selector({ features }))
|
||||
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
const files = [{ id: 'file-1', transfer_method: TransferMethod.remote_url }]
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
expect(callArgs.files).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not include files when no files provided', async () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'text',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = handleSend.mock.calls[0][1]
|
||||
expect(callArgs.files).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('features integration', () => {
|
||||
it('should include features in config', () => {
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||
|
||||
subscriptionCallback?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'test', files: [] },
|
||||
})
|
||||
|
||||
const handleSend = mockUseTextGeneration().handleSend
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
more_like_this: { enabled: true },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
text_to_speech: { enabled: true },
|
||||
file_upload: { enabled: true },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -6,18 +6,26 @@ import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
} from '@/app/components/base/chat/types'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import {
|
||||
AgentStrategy,
|
||||
AppModeEnum,
|
||||
ModelModeType,
|
||||
TransferMethod,
|
||||
} from '@/types/app'
|
||||
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
|
||||
import { ORCHESTRATE_CHANGED } from './types'
|
||||
@ -162,3 +170,111 @@ export const useFormattingChangedSubscription = (chatList: ChatItem[]) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const useInputValidation = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const {
|
||||
isAdvancedMode,
|
||||
mode,
|
||||
modelModeType,
|
||||
hasSetBlockStatus,
|
||||
modelConfig,
|
||||
} = useDebugConfigurationContext()
|
||||
|
||||
const logError = useCallback((message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}, [notify])
|
||||
|
||||
const checkCanSend = useCallback((inputs: Record<string, unknown>, completionFiles: VisionFile[]) => {
|
||||
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
|
||||
if (modelModeType === ModelModeType.completion) {
|
||||
if (!hasSetBlockStatus.history) {
|
||||
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
if (!hasSetBlockStatus.query) {
|
||||
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
let hasEmptyInput = ''
|
||||
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
|
||||
return false
|
||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||
return res
|
||||
})
|
||||
requiredVars.forEach(({ key, name }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (!inputs[key])
|
||||
hasEmptyInput = name
|
||||
})
|
||||
|
||||
if (hasEmptyInput) {
|
||||
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
|
||||
return false
|
||||
}
|
||||
|
||||
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
return !hasEmptyInput
|
||||
}, [
|
||||
hasSetBlockStatus.history,
|
||||
hasSetBlockStatus.query,
|
||||
isAdvancedMode,
|
||||
mode,
|
||||
modelConfig.configs.prompt_variables,
|
||||
t,
|
||||
logError,
|
||||
notify,
|
||||
modelModeType,
|
||||
])
|
||||
|
||||
return { checkCanSend, logError }
|
||||
}
|
||||
|
||||
export const useFormattingChangeConfirm = () => {
|
||||
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
|
||||
const { formattingChanged, setFormattingChanged } = useDebugConfigurationContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (formattingChanged)
|
||||
setIsShowFormattingChangeConfirm(true) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
}, [formattingChanged])
|
||||
|
||||
const handleConfirm = useCallback((onClear: () => void) => {
|
||||
onClear()
|
||||
setIsShowFormattingChangeConfirm(false)
|
||||
setFormattingChanged(false)
|
||||
}, [setFormattingChanged])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsShowFormattingChangeConfirm(false)
|
||||
setFormattingChanged(false)
|
||||
}, [setFormattingChanged])
|
||||
|
||||
return {
|
||||
isShowFormattingChangeConfirm,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
}
|
||||
}
|
||||
|
||||
export const useModalWidth = (containerRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
const [width, setWidth] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const calculatedWidth = document.body.clientWidth - (containerRef.current.clientWidth + 16) - 8
|
||||
setWidth(calculatedWidth) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
@ -3,54 +3,39 @@ import type { FC } from 'react'
|
||||
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
|
||||
import type { ModelAndParameter } from './types'
|
||||
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiEqualizer2Line,
|
||||
RiSparklingFill,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import type { Inputs, PromptVariable } from '@/models/debug'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
|
||||
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { sendCompletionMessage } from '@/service/debug'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
|
||||
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
|
||||
import GroupName from '../base/group-name'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CannotQueryDataset from '../base/warning-mask/cannot-query-dataset'
|
||||
import FormattingChanged from '../base/warning-mask/formatting-changed'
|
||||
import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
|
||||
import DebugHeader from './debug-header'
|
||||
import DebugWithMultipleModel from './debug-with-multiple-model'
|
||||
import DebugWithSingleModel from './debug-with-single-model'
|
||||
import { useFormattingChangeConfirm, useInputValidation, useModalWidth } from './hooks'
|
||||
import TextCompletionResult from './text-completion-result'
|
||||
import {
|
||||
APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
||||
} from './types'
|
||||
import { useTextCompletion } from './use-text-completion'
|
||||
|
||||
type IDebug = {
|
||||
isAPIKeySet: boolean
|
||||
@ -71,33 +56,17 @@ const Debug: FC<IDebug> = ({
|
||||
multipleModelConfigs,
|
||||
onMultipleModelConfigsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
readonly,
|
||||
appId,
|
||||
mode,
|
||||
modelModeType,
|
||||
hasSetBlockStatus,
|
||||
isAdvancedMode,
|
||||
promptMode,
|
||||
chatPromptConfig,
|
||||
completionPromptConfig,
|
||||
introduction,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
speechToTextConfig,
|
||||
textToSpeechConfig,
|
||||
citationConfig,
|
||||
formattingChanged,
|
||||
setFormattingChanged,
|
||||
dataSets,
|
||||
modelConfig,
|
||||
completionParams,
|
||||
hasSetContextVar,
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig,
|
||||
} = useContext(ConfigContext)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
// Disable immer auto-freeze for this component
|
||||
useEffect(() => {
|
||||
setAutoFreeze(false)
|
||||
return () => {
|
||||
@ -105,226 +74,77 @@ const Debug: FC<IDebug> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
|
||||
// UI state
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (formattingChanged)
|
||||
setIsShowFormattingChangeConfirm(true)
|
||||
}, [formattingChanged])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
|
||||
const handleClearConversation = () => {
|
||||
debugWithSingleModelRef.current?.handleRestart()
|
||||
}
|
||||
const clearConversation = async () => {
|
||||
if (debugWithMultipleModel) {
|
||||
eventEmitter?.emit({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
||||
} as any)
|
||||
return
|
||||
}
|
||||
|
||||
handleClearConversation()
|
||||
}
|
||||
// Hooks
|
||||
const { checkCanSend } = useInputValidation()
|
||||
const { isShowFormattingChangeConfirm, handleConfirm, handleCancel } = useFormattingChangeConfirm()
|
||||
const modalWidth = useModalWidth(containerRef)
|
||||
|
||||
const handleConfirm = () => {
|
||||
clearConversation()
|
||||
setIsShowFormattingChangeConfirm(false)
|
||||
setFormattingChanged(false)
|
||||
}
|
||||
// Wrapper for checkCanSend that uses current completionFiles
|
||||
const [completionFilesForValidation, setCompletionFilesForValidation] = useState<VisionFile[]>([])
|
||||
const checkCanSendWithFiles = useCallback(() => {
|
||||
return checkCanSend(inputs, completionFilesForValidation)
|
||||
}, [checkCanSend, inputs, completionFilesForValidation])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsShowFormattingChangeConfirm(false)
|
||||
setFormattingChanged(false)
|
||||
}
|
||||
|
||||
const { notify } = useContext(ToastContext)
|
||||
const logError = useCallback((message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}, [notify])
|
||||
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
|
||||
|
||||
const checkCanSend = useCallback(() => {
|
||||
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
|
||||
if (modelModeType === ModelModeType.completion) {
|
||||
if (!hasSetBlockStatus.history) {
|
||||
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
if (!hasSetBlockStatus.query) {
|
||||
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
let hasEmptyInput = ''
|
||||
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
|
||||
return false
|
||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||
return res
|
||||
}) // compatible with old version
|
||||
requiredVars.forEach(({ key, name }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (!inputs[key])
|
||||
hasEmptyInput = name
|
||||
})
|
||||
|
||||
if (hasEmptyInput) {
|
||||
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
|
||||
return false
|
||||
}
|
||||
|
||||
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
return !hasEmptyInput
|
||||
}, [
|
||||
const {
|
||||
isResponding,
|
||||
completionRes,
|
||||
messageId,
|
||||
completionFiles,
|
||||
hasSetBlockStatus.history,
|
||||
hasSetBlockStatus.query,
|
||||
inputs,
|
||||
isAdvancedMode,
|
||||
mode,
|
||||
modelConfig.configs.prompt_variables,
|
||||
t,
|
||||
logError,
|
||||
notify,
|
||||
modelModeType,
|
||||
])
|
||||
|
||||
const [completionRes, setCompletionRes] = useState('')
|
||||
const [messageId, setMessageId] = useState<string | null>(null)
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
const sendTextCompletion = async () => {
|
||||
if (isResponding) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (dataSets.length > 0 && !hasSetContextVar) {
|
||||
setShowCannotQueryDataset(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!checkCanSend())
|
||||
return
|
||||
|
||||
const postDatasets = dataSets.map(({ id }) => ({
|
||||
dataset: {
|
||||
enabled: true,
|
||||
id,
|
||||
},
|
||||
}))
|
||||
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
|
||||
|
||||
const postModelConfig: BackendModelConfig = {
|
||||
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
|
||||
prompt_type: promptMode,
|
||||
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
|
||||
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
||||
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
|
||||
dataset_query_variable: contextVar || '',
|
||||
dataset_configs: {
|
||||
...datasetConfigs,
|
||||
datasets: {
|
||||
datasets: [...postDatasets],
|
||||
} as any,
|
||||
},
|
||||
agent_mode: {
|
||||
enabled: false,
|
||||
tools: [],
|
||||
},
|
||||
model: {
|
||||
provider: modelConfig.provider,
|
||||
name: modelConfig.model_id,
|
||||
mode: modelConfig.mode,
|
||||
completion_params: completionParams as any,
|
||||
},
|
||||
more_like_this: features.moreLikeThis as any,
|
||||
sensitive_word_avoidance: features.moderation as any,
|
||||
text_to_speech: features.text2speech as any,
|
||||
file_upload: features.file as any,
|
||||
opening_statement: introduction,
|
||||
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
|
||||
speech_to_text: speechToTextConfig,
|
||||
retriever_resource: citationConfig,
|
||||
system_parameters: modelConfig.system_parameters,
|
||||
external_data_tools: externalDataToolsConfig,
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {
|
||||
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
|
||||
model_config: postModelConfig,
|
||||
}
|
||||
|
||||
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
|
||||
data.files = completionFiles.map((item) => {
|
||||
if (item.transfer_method === TransferMethod.local_file) {
|
||||
return {
|
||||
...item,
|
||||
url: '',
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
setCompletionRes('')
|
||||
setMessageId('')
|
||||
let res: string[] = []
|
||||
|
||||
setRespondingTrue()
|
||||
sendCompletionMessage(appId, data, {
|
||||
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
|
||||
res.push(data)
|
||||
setCompletionRes(res.join(''))
|
||||
setMessageId(messageId)
|
||||
},
|
||||
onMessageReplace: (messageReplace) => {
|
||||
res = [messageReplace.answer]
|
||||
setCompletionRes(res.join(''))
|
||||
},
|
||||
onCompleted() {
|
||||
setRespondingFalse()
|
||||
},
|
||||
onError() {
|
||||
setRespondingFalse()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSendTextCompletion = () => {
|
||||
if (debugWithMultipleModel) {
|
||||
eventEmitter?.emit({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: {
|
||||
message: '',
|
||||
files: completionFiles,
|
||||
},
|
||||
} as any)
|
||||
return
|
||||
}
|
||||
|
||||
sendTextCompletion()
|
||||
}
|
||||
|
||||
const varList = modelConfig.configs.prompt_variables.map((item: any) => {
|
||||
return {
|
||||
label: item.key,
|
||||
value: inputs[item.key],
|
||||
}
|
||||
setCompletionFiles,
|
||||
sendTextCompletion,
|
||||
} = useTextCompletion({
|
||||
checkCanSend: checkCanSendWithFiles,
|
||||
onShowCannotQueryDataset: () => setShowCannotQueryDataset(true),
|
||||
})
|
||||
|
||||
// Sync completionFiles for validation
|
||||
useEffect(() => {
|
||||
setCompletionFilesForValidation(completionFiles as VisionFile[]) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
}, [completionFiles])
|
||||
|
||||
// App store for modals
|
||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||
currentLogItem: state.currentLogItem,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
showPromptLogModal: state.showPromptLogModal,
|
||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||
showAgentLogModal: state.showAgentLogModal,
|
||||
setShowAgentLogModal: state.setShowAgentLogModal,
|
||||
})))
|
||||
|
||||
// Provider context for model list
|
||||
const { textGenerationModelList } = useProviderContext()
|
||||
const handleChangeToSingleModel = (item: ModelAndParameter) => {
|
||||
|
||||
// Computed values
|
||||
const varList = modelConfig.configs.prompt_variables.map((item: PromptVariable) => ({
|
||||
label: item.key,
|
||||
value: inputs[item.key],
|
||||
}))
|
||||
|
||||
// Handlers
|
||||
const handleClearConversation = useCallback(() => {
|
||||
debugWithSingleModelRef.current?.handleRestart()
|
||||
}, [])
|
||||
|
||||
const clearConversation = useCallback(async () => {
|
||||
if (debugWithMultipleModel) {
|
||||
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } as any) // eslint-disable-line ts/no-explicit-any
|
||||
return
|
||||
}
|
||||
handleClearConversation()
|
||||
}, [debugWithMultipleModel, eventEmitter, handleClearConversation])
|
||||
|
||||
const handleFormattingConfirm = useCallback(() => {
|
||||
handleConfirm(clearConversation)
|
||||
}, [handleConfirm, clearConversation])
|
||||
|
||||
const handleChangeToSingleModel = useCallback((item: ModelAndParameter) => {
|
||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === item.model)
|
||||
|
||||
@ -335,26 +155,18 @@ const Debug: FC<IDebug> = ({
|
||||
features: currentModel?.features,
|
||||
})
|
||||
modelParameterParams.onCompletionParamsChange(item.parameters)
|
||||
onMultipleModelConfigsChange(
|
||||
false,
|
||||
[],
|
||||
)
|
||||
}
|
||||
onMultipleModelConfigsChange(false, [])
|
||||
}, [modelParameterParams, onMultipleModelConfigsChange, textGenerationModelList])
|
||||
|
||||
const handleVisionConfigInMultipleModel = useCallback(() => {
|
||||
if (debugWithMultipleModel && mode) {
|
||||
const supportedVision = multipleModelConfigs.some((modelConfig) => {
|
||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === modelConfig.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model)
|
||||
|
||||
const supportedVision = multipleModelConfigs.some((config) => {
|
||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === config.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === config.model)
|
||||
return currentModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
})
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
const { features: storeFeatures, setFeatures } = featuresStore!.getState()
|
||||
const newFeatures = produce(storeFeatures, (draft) => {
|
||||
draft.file = {
|
||||
...draft.file,
|
||||
enabled: supportedVision,
|
||||
@ -368,210 +180,131 @@ const Debug: FC<IDebug> = ({
|
||||
handleVisionConfigInMultipleModel()
|
||||
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
|
||||
|
||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||
currentLogItem: state.currentLogItem,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
showPromptLogModal: state.showPromptLogModal,
|
||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||
showAgentLogModal: state.showAgentLogModal,
|
||||
setShowAgentLogModal: state.setShowAgentLogModal,
|
||||
})))
|
||||
const [width, setWidth] = useState(0)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const handleSendTextCompletion = useCallback(() => {
|
||||
if (debugWithMultipleModel) {
|
||||
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message: '', files: completionFiles } } as any) // eslint-disable-line ts/no-explicit-any
|
||||
return
|
||||
}
|
||||
sendTextCompletion()
|
||||
}, [completionFiles, debugWithMultipleModel, eventEmitter, sendTextCompletion])
|
||||
|
||||
const adjustModalWidth = () => {
|
||||
if (ref.current)
|
||||
setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8)
|
||||
}
|
||||
const handleAddModel = useCallback(() => {
|
||||
onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])
|
||||
}, [multipleModelConfigs, onMultipleModelConfigsChange])
|
||||
|
||||
useEffect(() => {
|
||||
adjustModalWidth()
|
||||
}, [])
|
||||
const handleClosePromptLogModal = useCallback(() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}, [setCurrentLogItem, setShowPromptLogModal])
|
||||
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const handleCloseAgentLogModal = useCallback(() => {
|
||||
setCurrentLogItem()
|
||||
setShowAgentLogModal(false)
|
||||
}, [setCurrentLogItem, setShowAgentLogModal])
|
||||
|
||||
const isShowTextToSpeech = features.text2speech?.enabled && !!text2speechDefaultModel
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
|
||||
<div className="flex items-center">
|
||||
{
|
||||
debugWithMultipleModel
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost-accent"
|
||||
onClick={() => onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])}
|
||||
disabled={multipleModelConfigs.length >= 4}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||
{t('modelProvider.addModel', { ns: 'common' })}
|
||||
(
|
||||
{multipleModelConfigs.length}
|
||||
/4)
|
||||
</Button>
|
||||
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
|
||||
</>
|
||||
)
|
||||
: null
|
||||
}
|
||||
{mode !== AppModeEnum.COMPLETION && (
|
||||
<>
|
||||
{
|
||||
!readonly && (
|
||||
<TooltipPlus
|
||||
popupContent={t('operation.refresh', { ns: 'common' })}
|
||||
>
|
||||
<ActionButton onClick={clearConversation}>
|
||||
<RefreshCcw01 className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
|
||||
</TooltipPlus>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
varList.length > 0 && (
|
||||
<div className="relative ml-1 mr-2">
|
||||
<TooltipPlus
|
||||
popupContent={t('panel.userInputField', { ns: 'workflow' })}
|
||||
>
|
||||
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</TooltipPlus>
|
||||
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DebugHeader
|
||||
readonly={readonly}
|
||||
mode={mode}
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
varListLength={varList.length}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
onClearConversation={clearConversation}
|
||||
onAddModel={handleAddModel}
|
||||
/>
|
||||
{mode !== AppModeEnum.COMPLETION && expanded && (
|
||||
<div className="mx-3">
|
||||
<ChatUserInput inputs={inputs} />
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
mode === AppModeEnum.COMPLETION && (
|
||||
<PromptValuePanel
|
||||
appType={mode as AppModeEnum}
|
||||
onSend={handleSendTextCompletion}
|
||||
inputs={inputs}
|
||||
visionConfig={{
|
||||
...features.file! as VisionSettings,
|
||||
transfer_methods: features.file!.allowed_file_upload_methods || [],
|
||||
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
|
||||
}}
|
||||
onVisionFilesChange={setCompletionFiles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
debugWithMultipleModel && (
|
||||
<div className="mt-3 grow overflow-hidden" ref={ref}>
|
||||
<DebugWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
onDebugWithMultipleModelChange={handleChangeToSingleModel}
|
||||
checkCanSend={checkCanSend}
|
||||
/>
|
||||
{showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAgentLogModal && (
|
||||
<AgentLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowAgentLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!debugWithMultipleModel && (
|
||||
<div className="flex grow flex-col" ref={ref}>
|
||||
{/* Chat */}
|
||||
{mode !== AppModeEnum.COMPLETION && (
|
||||
<div className="h-0 grow overflow-hidden">
|
||||
<DebugWithSingleModel
|
||||
ref={debugWithSingleModelRef}
|
||||
checkCanSend={checkCanSend}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Text Generation */}
|
||||
{mode === AppModeEnum.COMPLETION && (
|
||||
<>
|
||||
{(completionRes || isResponding) && (
|
||||
<>
|
||||
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
|
||||
<div className="mx-3 mb-8">
|
||||
<TextGeneration
|
||||
appSourceType={AppSourceType.webApp}
|
||||
className="mt-2"
|
||||
content={completionRes}
|
||||
isLoading={!completionRes && isResponding}
|
||||
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
|
||||
isResponding={isResponding}
|
||||
messageId={messageId}
|
||||
isError={false}
|
||||
onRetry={noop}
|
||||
siteInfo={null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!completionRes && !isResponding && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
|
||||
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isShowCannotQueryDataset && (
|
||||
<CannotQueryDataset
|
||||
onConfirm={() => setShowCannotQueryDataset(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isShowFormattingChangeConfirm && (
|
||||
<FormattingChanged
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
{mode === AppModeEnum.COMPLETION && (
|
||||
<PromptValuePanel
|
||||
appType={mode as AppModeEnum}
|
||||
onSend={handleSendTextCompletion}
|
||||
inputs={inputs}
|
||||
visionConfig={{
|
||||
...features.file! as VisionSettings,
|
||||
transfer_methods: features.file!.allowed_file_upload_methods || [],
|
||||
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
|
||||
}}
|
||||
onVisionFilesChange={setCompletionFiles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{debugWithMultipleModel && (
|
||||
<div className="mt-3 grow overflow-hidden" ref={containerRef}>
|
||||
<DebugWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
onDebugWithMultipleModelChange={handleChangeToSingleModel}
|
||||
checkCanSend={checkCanSendWithFiles}
|
||||
/>
|
||||
{showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={modalWidth}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={handleClosePromptLogModal}
|
||||
/>
|
||||
)}
|
||||
{showAgentLogModal && (
|
||||
<AgentLogModal
|
||||
width={modalWidth}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={handleCloseAgentLogModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!debugWithMultipleModel && (
|
||||
<div className="flex grow flex-col" ref={containerRef}>
|
||||
{mode !== AppModeEnum.COMPLETION && (
|
||||
<div className="h-0 grow overflow-hidden">
|
||||
<DebugWithSingleModel
|
||||
ref={debugWithSingleModelRef}
|
||||
checkCanSend={checkCanSendWithFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mode === AppModeEnum.COMPLETION && (
|
||||
<TextCompletionResult
|
||||
completionRes={completionRes}
|
||||
isResponding={isResponding}
|
||||
messageId={messageId}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
/>
|
||||
)}
|
||||
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={modalWidth}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={handleClosePromptLogModal}
|
||||
/>
|
||||
)}
|
||||
{isShowCannotQueryDataset && (
|
||||
<CannotQueryDataset onConfirm={() => setShowCannotQueryDataset(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isShowFormattingChangeConfirm && (
|
||||
<FormattingChanged
|
||||
onConfirm={handleFormattingConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
{!isAPIKeySet && !readonly && (
|
||||
<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Debug)
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiSparklingFill } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import GroupName from '../base/group-name'
|
||||
|
||||
type TextCompletionResultProps = {
|
||||
completionRes: string
|
||||
isResponding: boolean
|
||||
messageId: string | null
|
||||
isShowTextToSpeech?: boolean
|
||||
}
|
||||
|
||||
const TextCompletionResult: FC<TextCompletionResultProps> = ({
|
||||
completionRes,
|
||||
isResponding,
|
||||
messageId,
|
||||
isShowTextToSpeech,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!completionRes && !isResponding) {
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
|
||||
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-4 mt-3">
|
||||
<GroupName name={t('result', { ns: 'appDebug' })} />
|
||||
</div>
|
||||
<div className="mx-3 mb-8">
|
||||
<TextGeneration
|
||||
appSourceType={AppSourceType.webApp}
|
||||
className="mt-2"
|
||||
content={completionRes}
|
||||
isLoading={!completionRes && isResponding}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
isResponding={isResponding}
|
||||
messageId={messageId}
|
||||
isError={false}
|
||||
onRetry={noop}
|
||||
siteInfo={null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextCompletionResult
|
||||
@ -0,0 +1,187 @@
|
||||
import type { ModelConfig as BackendModelConfig, VisionFile } from '@/types/app'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useFeatures } from '@/app/components/base/features/hooks'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
||||
import { sendCompletionMessage } from '@/service/debug'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
|
||||
|
||||
type UseTextCompletionOptions = {
|
||||
checkCanSend: () => boolean
|
||||
onShowCannotQueryDataset: () => void
|
||||
}
|
||||
|
||||
export const useTextCompletion = ({
|
||||
checkCanSend,
|
||||
onShowCannotQueryDataset,
|
||||
}: UseTextCompletionOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const {
|
||||
appId,
|
||||
isAdvancedMode,
|
||||
promptMode,
|
||||
chatPromptConfig,
|
||||
completionPromptConfig,
|
||||
introduction,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
speechToTextConfig,
|
||||
citationConfig,
|
||||
dataSets,
|
||||
modelConfig,
|
||||
completionParams,
|
||||
hasSetContextVar,
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig,
|
||||
inputs,
|
||||
} = useDebugConfigurationContext()
|
||||
const features = useFeatures(s => s.features)
|
||||
|
||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||
const [completionRes, setCompletionRes] = useState('')
|
||||
const [messageId, setMessageId] = useState<string | null>(null)
|
||||
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
|
||||
|
||||
const sendTextCompletion = useCallback(async () => {
|
||||
if (isResponding) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (dataSets.length > 0 && !hasSetContextVar) {
|
||||
onShowCannotQueryDataset()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!checkCanSend())
|
||||
return
|
||||
|
||||
const postDatasets = dataSets.map(({ id }) => ({
|
||||
dataset: {
|
||||
enabled: true,
|
||||
id,
|
||||
},
|
||||
}))
|
||||
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
|
||||
|
||||
const postModelConfig: BackendModelConfig = {
|
||||
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
|
||||
prompt_type: promptMode,
|
||||
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
|
||||
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
||||
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
|
||||
dataset_query_variable: contextVar || '',
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
dataset_configs: {
|
||||
...datasetConfigs,
|
||||
datasets: {
|
||||
datasets: [...postDatasets],
|
||||
} as any,
|
||||
},
|
||||
agent_mode: {
|
||||
enabled: false,
|
||||
tools: [],
|
||||
},
|
||||
model: {
|
||||
provider: modelConfig.provider,
|
||||
name: modelConfig.model_id,
|
||||
mode: modelConfig.mode,
|
||||
completion_params: completionParams as any,
|
||||
},
|
||||
more_like_this: features.moreLikeThis as any,
|
||||
sensitive_word_avoidance: features.moderation as any,
|
||||
text_to_speech: features.text2speech as any,
|
||||
file_upload: features.file as any,
|
||||
/* eslint-enable ts/no-explicit-any */
|
||||
opening_statement: introduction,
|
||||
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
|
||||
speech_to_text: speechToTextConfig,
|
||||
retriever_resource: citationConfig,
|
||||
system_parameters: modelConfig.system_parameters,
|
||||
external_data_tools: externalDataToolsConfig,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const data: Record<string, any> = {
|
||||
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
|
||||
model_config: postModelConfig,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
|
||||
data.files = completionFiles.map((item) => {
|
||||
if (item.transfer_method === TransferMethod.local_file) {
|
||||
return {
|
||||
...item,
|
||||
url: '',
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
setCompletionRes('')
|
||||
setMessageId('')
|
||||
let res: string[] = []
|
||||
|
||||
setRespondingTrue()
|
||||
sendCompletionMessage(appId, data, {
|
||||
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
|
||||
res.push(data)
|
||||
setCompletionRes(res.join(''))
|
||||
setMessageId(messageId)
|
||||
},
|
||||
onMessageReplace: (messageReplace) => {
|
||||
res = [messageReplace.answer]
|
||||
setCompletionRes(res.join(''))
|
||||
},
|
||||
onCompleted() {
|
||||
setRespondingFalse()
|
||||
},
|
||||
onError() {
|
||||
setRespondingFalse()
|
||||
},
|
||||
})
|
||||
}, [
|
||||
appId,
|
||||
checkCanSend,
|
||||
chatPromptConfig,
|
||||
citationConfig,
|
||||
completionFiles,
|
||||
completionParams,
|
||||
completionPromptConfig,
|
||||
datasetConfigs,
|
||||
dataSets,
|
||||
externalDataToolsConfig,
|
||||
features,
|
||||
hasSetContextVar,
|
||||
inputs,
|
||||
introduction,
|
||||
isAdvancedMode,
|
||||
isResponding,
|
||||
modelConfig,
|
||||
notify,
|
||||
onShowCannotQueryDataset,
|
||||
promptMode,
|
||||
setRespondingFalse,
|
||||
setRespondingTrue,
|
||||
speechToTextConfig,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
t,
|
||||
])
|
||||
|
||||
return {
|
||||
isResponding,
|
||||
completionRes,
|
||||
messageId,
|
||||
completionFiles,
|
||||
setCompletionFiles,
|
||||
sendTextCompletion,
|
||||
}
|
||||
}
|
||||
221
web/app/components/tools/mcp/create-card.spec.tsx
Normal file
221
web/app/components/tools/mcp/create-card.spec.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import NewMCPCard from './create-card'
|
||||
|
||||
// Track the mock functions
|
||||
const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' })
|
||||
|
||||
// Mock the service
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useCreateMCP: () => ({
|
||||
mutateAsync: mockCreateMCP,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the MCP Modal
|
||||
type MockMCPModalProps = {
|
||||
show: boolean
|
||||
onConfirm: (info: { name: string, server_url: string }) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
vi.mock('./modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: MockMCPModalProps) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="mcp-modal">
|
||||
<span>tools.mcp.modal.title</span>
|
||||
<button data-testid="confirm-btn" onClick={() => onConfirm({ name: 'Test MCP', server_url: 'https://test.com' })}>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="close-btn" onClick={onHide}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mutable workspace manager state
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
|
||||
// Mock the app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the plugins service
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { pages: [] },
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock common service
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
|
||||
}))
|
||||
|
||||
describe('NewMCPCard', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
handleCreate: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateMCP.mockClear()
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render card title', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.create.cardLink')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add icon', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
const svgElements = document.querySelectorAll('svg')
|
||||
expect(svgElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open modal when card is clicked', async () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
|
||||
const clickableArea = cardTitle.closest('.group')
|
||||
|
||||
if (clickableArea) {
|
||||
fireEvent.click(clickableArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should have documentation link with correct target', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const docLink = screen.getByText('tools.mcp.create.cardLink').closest('a')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Manager User', () => {
|
||||
it('should not render card when user is not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct card structure', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const card = document.querySelector('.rounded-xl')
|
||||
expect(card).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have clickable cursor style', () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const card = document.querySelector('.cursor-pointer')
|
||||
expect(card).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Interactions', () => {
|
||||
it('should call create function when modal confirms', async () => {
|
||||
const handleCreate = vi.fn()
|
||||
render(<NewMCPCard handleCreate={handleCreate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the modal
|
||||
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
|
||||
const clickableArea = cardTitle.closest('.group')
|
||||
|
||||
if (clickableArea) {
|
||||
fireEvent.click(clickableArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click confirm
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMCP).toHaveBeenCalledWith({
|
||||
name: 'Test MCP',
|
||||
server_url: 'https://test.com',
|
||||
})
|
||||
expect(handleCreate).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should close modal when close button is clicked', async () => {
|
||||
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the modal
|
||||
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
|
||||
const clickableArea = cardTitle.closest('.group')
|
||||
|
||||
if (clickableArea) {
|
||||
fireEvent.click(clickableArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click close
|
||||
const closeBtn = screen.getByTestId('close-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
855
web/app/components/tools/mcp/detail/content.spec.tsx
Normal file
855
web/app/components/tools/mcp/detail/content.spec.tsx
Normal file
@ -0,0 +1,855 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MCPDetailContent from './content'
|
||||
|
||||
// Mutable mock functions
|
||||
const mockUpdateTools = vi.fn().mockResolvedValue({})
|
||||
const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockInvalidateMCPTools = vi.fn()
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
|
||||
// Mutable mock state
|
||||
type MockTool = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
let mockToolsData: { tools: MockTool[] } = { tools: [] }
|
||||
let mockIsFetching = false
|
||||
let mockIsUpdating = false
|
||||
let mockIsAuthorizing = false
|
||||
|
||||
// Mock the services
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useMCPTools: () => ({
|
||||
data: mockToolsData,
|
||||
isFetching: mockIsFetching,
|
||||
}),
|
||||
useInvalidateMCPTools: () => mockInvalidateMCPTools,
|
||||
useUpdateMCPTools: () => ({
|
||||
mutateAsync: mockUpdateTools,
|
||||
isPending: mockIsUpdating,
|
||||
}),
|
||||
useAuthorizeMCP: () => ({
|
||||
mutateAsync: mockAuthorizeMcp,
|
||||
isPending: mockIsAuthorizing,
|
||||
}),
|
||||
useUpdateMCP: () => ({
|
||||
mutateAsync: mockUpdateMCP,
|
||||
}),
|
||||
useDeleteMCP: () => ({
|
||||
mutateAsync: mockDeleteMCP,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock OAuth hook
|
||||
type OAuthArgs = readonly unknown[]
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: (...args: OAuthArgs) => mockOpenOAuthPopup(...args),
|
||||
}))
|
||||
|
||||
// Mock MCPModal
|
||||
type MCPModalData = {
|
||||
name: string
|
||||
server_url: string
|
||||
}
|
||||
|
||||
type MCPModalProps = {
|
||||
show: boolean
|
||||
onConfirm: (data: MCPModalData) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
vi.mock('../modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="mcp-update-modal">
|
||||
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="modal-close-btn" onClick={onHide}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Confirm dialog
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-dialog" data-title={title}>
|
||||
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock OperationDropdown
|
||||
vi.mock('./operation-dropdown', () => ({
|
||||
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
|
||||
<div data-testid="operation-dropdown">
|
||||
<button data-testid="edit-btn" onClick={onEdit}>Edit</button>
|
||||
<button data-testid="remove-btn" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock ToolItem
|
||||
type ToolItemData = {
|
||||
name: string
|
||||
}
|
||||
|
||||
vi.mock('./tool-item', () => ({
|
||||
default: ({ tool }: { tool: ToolItemData }) => (
|
||||
<div data-testid="tool-item">{tool.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mutable workspace manager state
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
|
||||
// Mock the app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the plugins service
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { pages: [] },
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock common service
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('MCPDetailContent', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockDetail = (overrides = {}): ToolWithProvider => ({
|
||||
id: 'mcp-1',
|
||||
name: 'Test MCP Server',
|
||||
server_identifier: 'test-mcp',
|
||||
server_url: 'https://example.com/mcp',
|
||||
icon: { content: '🔧', background: '#FF0000' },
|
||||
tools: [],
|
||||
is_team_authorization: false,
|
||||
...overrides,
|
||||
} as unknown as ToolWithProvider)
|
||||
|
||||
const defaultProps = {
|
||||
detail: createMockDetail(),
|
||||
onUpdate: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
isTriggerAuthorize: false,
|
||||
onFirstCreate: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
mockUpdateTools.mockClear()
|
||||
mockAuthorizeMcp.mockClear()
|
||||
mockUpdateMCP.mockClear()
|
||||
mockDeleteMCP.mockClear()
|
||||
mockInvalidateMCPTools.mockClear()
|
||||
mockOpenOAuthPopup.mockClear()
|
||||
|
||||
// Reset mock return values
|
||||
mockUpdateTools.mockResolvedValue({})
|
||||
mockAuthorizeMcp.mockResolvedValue({ result: 'success' })
|
||||
mockUpdateMCP.mockResolvedValue({ result: 'success' })
|
||||
mockDeleteMCP.mockResolvedValue({ result: 'success' })
|
||||
|
||||
// Reset state
|
||||
mockToolsData = { tools: [] }
|
||||
mockIsFetching = false
|
||||
mockIsUpdating = false
|
||||
mockIsAuthorizing = false
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display MCP name', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display server identifier', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('test-mcp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display server URL', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Close button should be present
|
||||
const closeButtons = document.querySelectorAll('button')
|
||||
expect(closeButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render operation dropdown', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Operation dropdown trigger should be present
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorization State', () => {
|
||||
it('should show authorize button when not authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorized button when authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorization required message when not authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorization tip', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.authorizeTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty Tools State', () => {
|
||||
it('should show empty message when authorized but no tools', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.toolsEmpty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show get tools button when empty', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should render MCP icon', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Icon container should be present
|
||||
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty server URL', () => {
|
||||
const detail = createMockDetail({ server_url: '' })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long MCP name', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
const detail = createMockDetail({ name: longName })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText(longName)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tools List', () => {
|
||||
it('should show tools list when authorized and has tools', () => {
|
||||
mockToolsData = {
|
||||
tools: [
|
||||
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
|
||||
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
|
||||
],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tool1')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show single tool label when only one tool', () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.onlyTool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tools count when multiple tools', () => {
|
||||
mockToolsData = {
|
||||
tools: [
|
||||
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
|
||||
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
|
||||
],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText(/tools.mcp.toolsNum/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show loading state when fetching tools', () => {
|
||||
mockIsFetching = true
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.gettingTools')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show updating state when updating tools', () => {
|
||||
mockIsUpdating = true
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.updateTools')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorizing button when authorizing', () => {
|
||||
mockIsAuthorizing = true
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
// Multiple elements show authorizing text - use getAllByText
|
||||
const authorizingElements = screen.getAllByText('tools.mcp.authorizing')
|
||||
expect(authorizingElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorize Flow', () => {
|
||||
it('should call authorizeMcp when authorize button is clicked', async () => {
|
||||
const onFirstCreate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
fireEvent.click(authorizeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFirstCreate).toHaveBeenCalled()
|
||||
expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should open OAuth popup when authorization_url is returned', async () => {
|
||||
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
fireEvent.click(authorizeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
|
||||
'https://oauth.example.com',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should trigger authorize on mount when isTriggerAuthorize is true', async () => {
|
||||
const onFirstCreate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} isTriggerAuthorize={true} onFirstCreate={onFirstCreate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFirstCreate).toHaveBeenCalled()
|
||||
expect(mockAuthorizeMcp).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable authorize button when not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
expect(authorizeBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Tools Flow', () => {
|
||||
it('should show update confirm dialog when update button is clicked', async () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const updateBtn = screen.getByText('tools.mcp.update')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateTools when update is confirmed', async () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Open confirm dialog
|
||||
const updateBtn = screen.getByText('tools.mcp.update')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm the update
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleUpdateTools when get tools button is clicked', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const getToolsBtn = screen.getByText('tools.mcp.getTools')
|
||||
fireEvent.click(getToolsBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update MCP Modal', () => {
|
||||
it('should open update modal when edit button is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close update modal when close button is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal
|
||||
const closeBtn = screen.getByTestId('modal-close-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mcp-update-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateMCP when form is confirmed', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm form
|
||||
const confirmBtn = screen.getByTestId('modal-confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMCP).toHaveBeenCalledWith({
|
||||
name: 'Updated MCP',
|
||||
server_url: 'https://updated.com',
|
||||
provider_id: 'mcp-1',
|
||||
})
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onUpdate when updateMCP fails', async () => {
|
||||
mockUpdateMCP.mockResolvedValue({ result: 'error' })
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm form
|
||||
const confirmBtn = screen.getByTestId('modal-confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMCP).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete MCP Flow', () => {
|
||||
it('should open delete confirm when remove button is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close delete confirm when cancel is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open confirm
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cancel
|
||||
const cancelBtn = screen.getByTestId('cancel-btn')
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deleteMCP when delete is confirmed', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open confirm
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
|
||||
expect(onUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onUpdate when deleteMCP fails', async () => {
|
||||
mockDeleteMCP.mockResolvedValue({ result: 'error' })
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open confirm
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMCP).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close Button', () => {
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the close button (ActionButton with RiCloseLine)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const closeButton = buttons.find(btn =>
|
||||
btn.querySelector('svg.h-4.w-4'),
|
||||
)
|
||||
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy Server Identifier', () => {
|
||||
it('should copy server identifier when clicked', async () => {
|
||||
const { default: copy } = await import('copy-to-clipboard')
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the server identifier element
|
||||
const serverIdentifier = screen.getByText('test-mcp')
|
||||
fireEvent.click(serverIdentifier)
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('test-mcp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth Callback', () => {
|
||||
it('should call handleUpdateTools on OAuth callback when authorized', async () => {
|
||||
// Simulate OAuth flow with authorization_url
|
||||
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Click authorize to trigger OAuth popup
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
fireEvent.click(authorizeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Get the callback function and call it
|
||||
const oauthCallback = mockOpenOAuthPopup.mock.calls[0][1]
|
||||
oauthCallback()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleUpdateTools if not workspace manager', async () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
|
||||
// OAuth callback should not trigger update for non-manager
|
||||
// The button is disabled, so we simulate a scenario where OAuth was already started
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Button should be disabled
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
expect(authorizeBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorized Button', () => {
|
||||
it('should show authorized button when team is authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleAuthorize when authorized button is clicked', async () => {
|
||||
const onFirstCreate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizedBtn = screen.getByText('tools.auth.authorized')
|
||||
fireEvent.click(authorizedBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFirstCreate).toHaveBeenCalled()
|
||||
expect(mockAuthorizeMcp).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable authorized button when not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizedBtn = screen.getByText('tools.auth.authorized')
|
||||
expect(authorizedBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Update Confirm', () => {
|
||||
it('should close update confirm when cancel is clicked', async () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Open confirm dialog
|
||||
const updateBtn = screen.getByText('tools.mcp.update')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cancel the update
|
||||
const cancelBtn = screen.getByTestId('cancel-btn')
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
71
web/app/components/tools/mcp/detail/list-loading.spec.tsx
Normal file
71
web/app/components/tools/mcp/detail/list-loading.spec.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ListLoading from './list-loading'
|
||||
|
||||
describe('ListLoading', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<ListLoading />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 5 skeleton items', () => {
|
||||
render(<ListLoading />)
|
||||
const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]')
|
||||
expect(skeletonItems.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should have rounded-xl class on skeleton items', () => {
|
||||
render(<ListLoading />)
|
||||
const skeletonItems = document.querySelectorAll('.rounded-xl')
|
||||
expect(skeletonItems.length).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('should have proper spacing', () => {
|
||||
render(<ListLoading />)
|
||||
const container = document.querySelector('.space-y-2')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder bars with different widths', () => {
|
||||
render(<ListLoading />)
|
||||
const bar180 = document.querySelector('.w-\\[180px\\]')
|
||||
const bar148 = document.querySelector('.w-\\[148px\\]')
|
||||
const bar196 = document.querySelector('.w-\\[196px\\]')
|
||||
|
||||
expect(bar180).toBeInTheDocument()
|
||||
expect(bar148).toBeInTheDocument()
|
||||
expect(bar196).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have opacity styling on skeleton bars', () => {
|
||||
render(<ListLoading />)
|
||||
const opacity20Bars = document.querySelectorAll('.opacity-20')
|
||||
const opacity10Bars = document.querySelectorAll('.opacity-10')
|
||||
|
||||
expect(opacity20Bars.length).toBeGreaterThan(0)
|
||||
expect(opacity10Bars.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Structure', () => {
|
||||
it('should have correct nested structure', () => {
|
||||
render(<ListLoading />)
|
||||
const items = document.querySelectorAll('.space-y-3')
|
||||
expect(items.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render padding on skeleton items', () => {
|
||||
render(<ListLoading />)
|
||||
const paddedItems = document.querySelectorAll('.p-4')
|
||||
expect(paddedItems.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render height-2 skeleton bars', () => {
|
||||
render(<ListLoading />)
|
||||
const h2Bars = document.querySelectorAll('.h-2')
|
||||
// 3 bars per skeleton item * 5 items = 15
|
||||
expect(h2Bars.length).toBe(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
193
web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx
Normal file
193
web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OperationDropdown from './operation-dropdown'
|
||||
|
||||
describe('OperationDropdown', () => {
|
||||
const defaultProps = {
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger button with more icon', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
const button = document.querySelector('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
const svg = button?.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render medium size by default', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
const icon = document.querySelector('.h-4.w-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render large size when inCard is true', () => {
|
||||
render(<OperationDropdown {...defaultProps} inCard={true} />)
|
||||
const icon = document.querySelector('.h-5.w-5')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropdown Behavior', () => {
|
||||
it('should open dropdown when trigger is clicked', async () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Dropdown content should be rendered
|
||||
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onOpenChange when opened', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger is clicked again', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu Actions', () => {
|
||||
it('should call onEdit when edit option is clicked', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const editOption = screen.getByText('tools.mcp.operation.edit')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onRemove when remove option is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const removeOption = screen.getByText('tools.mcp.operation.remove')
|
||||
fireEvent.click(removeOption)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should close dropdown after edit is clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
onOpenChange.mockClear()
|
||||
|
||||
const editOption = screen.getByText('tools.mcp.operation.edit')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('should close dropdown after remove is clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
onOpenChange.mockClear()
|
||||
|
||||
const removeOption = screen.getByText('tools.mcp.operation.remove')
|
||||
fireEvent.click(removeOption)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct dropdown width', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dropdown = document.querySelector('.w-\\[160px\\]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have rounded-xl on dropdown', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should show destructive hover style on remove option', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// The text is in a div, and the hover style is on the parent div with group class
|
||||
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
|
||||
const removeOptionContainer = removeOptionText.closest('.group')
|
||||
expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('inCard prop', () => {
|
||||
it('should adjust offset when inCard is false', () => {
|
||||
render(<OperationDropdown {...defaultProps} inCard={false} />)
|
||||
// Component renders with different offset values
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should adjust offset when inCard is true', () => {
|
||||
render(<OperationDropdown {...defaultProps} inCard={true} />)
|
||||
// Component renders with different offset values
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
153
web/app/components/tools/mcp/detail/provider-detail.spec.tsx
Normal file
153
web/app/components/tools/mcp/detail/provider-detail.spec.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCPDetailPanel from './provider-detail'
|
||||
|
||||
// Mock the drawer component
|
||||
vi.mock('@/app/components/base/drawer', () => ({
|
||||
default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
return <div data-testid="drawer">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the content component to expose onUpdate callback
|
||||
vi.mock('./content', () => ({
|
||||
default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => (
|
||||
<div data-testid="mcp-detail-content">
|
||||
{detail.name}
|
||||
<button data-testid="update-btn" onClick={() => onUpdate()}>Update</button>
|
||||
<button data-testid="delete-btn" onClick={() => onUpdate(true)}>Delete</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('MCPDetailPanel', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockDetail = (): ToolWithProvider => ({
|
||||
id: 'mcp-1',
|
||||
name: 'Test MCP',
|
||||
server_identifier: 'test-mcp',
|
||||
server_url: 'https://example.com/mcp',
|
||||
icon: { content: '🔧', background: '#FF0000' },
|
||||
tools: [],
|
||||
is_team_authorization: true,
|
||||
} as unknown as ToolWithProvider)
|
||||
|
||||
const defaultProps = {
|
||||
onUpdate: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
isTriggerAuthorize: false,
|
||||
onFirstCreate: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when detail is undefined', () => {
|
||||
const { container } = render(
|
||||
<MCPDetailPanel {...defaultProps} detail={undefined} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should render drawer when detail is provided', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when detail is provided', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass detail to content component', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Test MCP')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Callbacks', () => {
|
||||
it('should call onUpdate when update is triggered', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
// The update callback is passed to content component
|
||||
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept isTriggerAuthorize prop', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('should call onUpdate but not onHide when isDelete is false (default)', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Click update button which calls onUpdate() without isDelete parameter
|
||||
const updateBtn = screen.getByTestId('update-btn')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call both onHide and onUpdate when isDelete is true', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Click delete button which calls onUpdate(true)
|
||||
const deleteBtn = screen.getByTestId('delete-btn')
|
||||
fireEvent.click(deleteBtn)
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
126
web/app/components/tools/mcp/detail/tool-item.spec.tsx
Normal file
126
web/app/components/tools/mcp/detail/tool-item.spec.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import MCPToolItem from './tool-item'
|
||||
|
||||
describe('MCPToolItem', () => {
|
||||
const createMockTool = (overrides = {}): Tool => ({
|
||||
name: 'test-tool',
|
||||
label: {
|
||||
en_US: 'Test Tool',
|
||||
zh_Hans: '测试工具',
|
||||
},
|
||||
description: {
|
||||
en_US: 'A test tool description',
|
||||
zh_Hans: '测试工具描述',
|
||||
},
|
||||
parameters: [],
|
||||
...overrides,
|
||||
} as unknown as Tool)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display tool label', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display tool description', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('A test tool description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Parameters', () => {
|
||||
it('should not show parameters section when no parameters', () => {
|
||||
const tool = createMockTool({ parameters: [] })
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.queryByText('tools.mcp.toolItem.parameters')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with parameters', () => {
|
||||
const tool = createMockTool({
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
type: 'string',
|
||||
human_description: {
|
||||
en_US: 'A parameter description',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
// Tooltip content is rendered in portal, may not be visible immediately
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have cursor-pointer class', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const toolElement = document.querySelector('.cursor-pointer')
|
||||
expect(toolElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rounded-xl class', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const toolElement = document.querySelector('.rounded-xl')
|
||||
expect(toolElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have hover styles', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]')
|
||||
expect(toolElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty label', () => {
|
||||
const tool = createMockTool({
|
||||
label: { en_US: '', zh_Hans: '' },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
// Should render without crashing
|
||||
expect(document.querySelector('.cursor-pointer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty description', () => {
|
||||
const tool = createMockTool({
|
||||
description: { en_US: '', zh_Hans: '' },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description with line clamp', () => {
|
||||
const longDescription = 'This is a very long description '.repeat(20)
|
||||
const tool = createMockTool({
|
||||
description: { en_US: longDescription, zh_Hans: longDescription },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const descElement = document.querySelector('.line-clamp-2')
|
||||
expect(descElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in tool name', () => {
|
||||
const tool = createMockTool({
|
||||
name: 'special-tool_v2.0',
|
||||
label: { en_US: 'Special Tool <v2.0>', zh_Hans: '特殊工具' },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Special Tool <v2.0>')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
245
web/app/components/tools/mcp/headers-input.spec.tsx
Normal file
245
web/app/components/tools/mcp/headers-input.spec.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HeadersInput from './headers-input'
|
||||
|
||||
describe('HeadersInput', () => {
|
||||
const defaultProps = {
|
||||
headersItems: [],
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should render no headers message when empty', () => {
|
||||
render(<HeadersInput {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add header button when empty and not readonly', () => {
|
||||
render(<HeadersInput {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render add header button when empty and readonly', () => {
|
||||
render(<HeadersInput {...defaultProps} readonly={true} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with new item when add button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const addButton = screen.getByText('tools.mcp.modal.addHeader')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
key: '',
|
||||
value: '',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Headers', () => {
|
||||
const headersItems = [
|
||||
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
|
||||
{ id: '2', key: 'Content-Type', value: 'application/json' },
|
||||
]
|
||||
|
||||
it('should render header items', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render table headers', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete buttons for each item when not readonly', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
// Should have delete buttons for each header
|
||||
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
|
||||
expect(deleteButtons.length).toBe(headersItems.length)
|
||||
})
|
||||
|
||||
it('should not render delete buttons when readonly', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
|
||||
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
|
||||
expect(deleteButtons.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should render add button at bottom when not readonly', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render add button when readonly', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Masked Headers', () => {
|
||||
const headersItems = [{ id: '1', key: 'Secret', value: '***' }]
|
||||
|
||||
it('should show masked headers tip when isMasked is true', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={true} />)
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show masked headers tip when isMasked is false', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={false} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Item Interactions', () => {
|
||||
const headersItems = [
|
||||
{ id: '1', key: 'Header1', value: 'Value1' },
|
||||
]
|
||||
|
||||
it('should call onChange when key is changed', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
|
||||
|
||||
const keyInput = screen.getByDisplayValue('Header1')
|
||||
fireEvent.change(keyInput, { target: { value: 'NewHeader' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: '1', key: 'NewHeader', value: 'Value1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should call onChange when value is changed', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
|
||||
|
||||
const valueInput = screen.getByDisplayValue('Value1')
|
||||
fireEvent.change(valueInput, { target: { value: 'NewValue' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: '1', key: 'Header1', value: 'NewValue' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove item when delete button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
|
||||
|
||||
const deleteButton = document.querySelector('[class*="text-text-destructive"]')?.closest('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
}
|
||||
})
|
||||
|
||||
it('should add new item when add button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
|
||||
|
||||
const addButton = screen.getByText('tools.mcp.modal.addHeader')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: '1', key: 'Header1', value: 'Value1' },
|
||||
expect.objectContaining({ key: '', value: '' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Headers', () => {
|
||||
const headersItems = [
|
||||
{ id: '1', key: 'Header1', value: 'Value1' },
|
||||
{ id: '2', key: 'Header2', value: 'Value2' },
|
||||
{ id: '3', key: 'Header3', value: 'Value3' },
|
||||
]
|
||||
|
||||
it('should render all headers', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
expect(screen.getByDisplayValue('Header1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Header2')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Header3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update correct item when changed', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
|
||||
|
||||
const header2Input = screen.getByDisplayValue('Header2')
|
||||
fireEvent.change(header2Input, { target: { value: 'UpdatedHeader2' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: '1', key: 'Header1', value: 'Value1' },
|
||||
{ id: '2', key: 'UpdatedHeader2', value: 'Value2' },
|
||||
{ id: '3', key: 'Header3', value: 'Value3' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove correct item when deleted', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
|
||||
|
||||
// Find all delete buttons and click the second one
|
||||
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
|
||||
const secondDeleteButton = deleteButtons[1]?.closest('button')
|
||||
if (secondDeleteButton) {
|
||||
fireEvent.click(secondDeleteButton)
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: '1', key: 'Header1', value: 'Value1' },
|
||||
{ id: '3', key: 'Header3', value: 'Value3' },
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Readonly Mode', () => {
|
||||
const headersItems = [{ id: '1', key: 'ReadOnly', value: 'Value' }]
|
||||
|
||||
it('should make inputs readonly when readonly is true', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
|
||||
|
||||
const keyInput = screen.getByDisplayValue('ReadOnly')
|
||||
const valueInput = screen.getByDisplayValue('Value')
|
||||
|
||||
expect(keyInput).toHaveAttribute('readonly')
|
||||
expect(valueInput).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('should not make inputs readonly when readonly is false', () => {
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={false} />)
|
||||
|
||||
const keyInput = screen.getByDisplayValue('ReadOnly')
|
||||
const valueInput = screen.getByDisplayValue('Value')
|
||||
|
||||
expect(keyInput).not.toHaveAttribute('readonly')
|
||||
expect(valueInput).not.toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty key and value', () => {
|
||||
const headersItems = [{ id: '1', key: '', value: '' }]
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle special characters in header key', () => {
|
||||
const headersItems = [{ id: '1', key: 'X-Custom-Header', value: 'value' }]
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
expect(screen.getByDisplayValue('X-Custom-Header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle JSON value', () => {
|
||||
const headersItems = [{ id: '1', key: 'Data', value: '{"key":"value"}' }]
|
||||
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
|
||||
expect(screen.getByDisplayValue('{"key":"value"}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
500
web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts
Normal file
500
web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts
Normal file
@ -0,0 +1,500 @@
|
||||
import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MCPAuthMethod } from '@/app/components/tools/types'
|
||||
import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form'
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useMCPModalForm', () => {
|
||||
describe('Utility Functions', () => {
|
||||
describe('isValidUrl', () => {
|
||||
it('should return true for valid http URL', () => {
|
||||
expect(isValidUrl('http://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for valid https URL', () => {
|
||||
expect(isValidUrl('https://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for URL with path', () => {
|
||||
expect(isValidUrl('https://example.com/path/to/resource')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for URL with query params', () => {
|
||||
expect(isValidUrl('https://example.com?foo=bar')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid URL', () => {
|
||||
expect(isValidUrl('not-a-url')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for ftp URL', () => {
|
||||
expect(isValidUrl('ftp://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isValidUrl('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for file URL', () => {
|
||||
expect(isValidUrl('file:///path/to/file')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidServerID', () => {
|
||||
it('should return true for lowercase letters', () => {
|
||||
expect(isValidServerID('myserver')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for numbers', () => {
|
||||
expect(isValidServerID('123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for alphanumeric with hyphens', () => {
|
||||
expect(isValidServerID('my-server-123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for alphanumeric with underscores', () => {
|
||||
expect(isValidServerID('my_server_123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for max length (24 chars)', () => {
|
||||
expect(isValidServerID('abcdefghijklmnopqrstuvwx')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for uppercase letters', () => {
|
||||
expect(isValidServerID('MyServer')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for spaces', () => {
|
||||
expect(isValidServerID('my server')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for special characters', () => {
|
||||
expect(isValidServerID('my@server')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isValidServerID('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for string longer than 24 chars', () => {
|
||||
expect(isValidServerID('abcdefghijklmnopqrstuvwxy')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
describe('Create Mode (no data)', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
expect(result.current.isCreate).toBe(true)
|
||||
expect(result.current.formKey).toBe('create')
|
||||
expect(result.current.state.url).toBe('')
|
||||
expect(result.current.state.name).toBe('')
|
||||
expect(result.current.state.serverIdentifier).toBe('')
|
||||
expect(result.current.state.timeout).toBe(30)
|
||||
expect(result.current.state.sseReadTimeout).toBe(300)
|
||||
expect(result.current.state.headers).toEqual([])
|
||||
expect(result.current.state.authMethod).toBe(MCPAuthMethod.authentication)
|
||||
expect(result.current.state.isDynamicRegistration).toBe(true)
|
||||
expect(result.current.state.clientID).toBe('')
|
||||
expect(result.current.state.credentials).toBe('')
|
||||
})
|
||||
|
||||
it('should initialize with default emoji icon', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
expect(result.current.state.appIcon).toEqual({
|
||||
type: 'emoji',
|
||||
icon: '🔗',
|
||||
background: '#6366F1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode (with data)', () => {
|
||||
const mockData: ToolWithProvider = {
|
||||
id: 'test-id-123',
|
||||
name: 'Test MCP Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
icon: { content: '🚀', background: '#FF0000' },
|
||||
configuration: {
|
||||
timeout: 60,
|
||||
sse_read_timeout: 600,
|
||||
},
|
||||
masked_headers: {
|
||||
'Authorization': '***',
|
||||
'X-Custom': 'value',
|
||||
},
|
||||
is_dynamic_registration: false,
|
||||
authentication: {
|
||||
client_id: 'client-123',
|
||||
client_secret: 'secret-456',
|
||||
},
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
it('should initialize with data values', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.isCreate).toBe(false)
|
||||
expect(result.current.formKey).toBe('test-id-123')
|
||||
expect(result.current.state.url).toBe('https://example.com/mcp')
|
||||
expect(result.current.state.name).toBe('Test MCP Server')
|
||||
expect(result.current.state.serverIdentifier).toBe('test-server')
|
||||
expect(result.current.state.timeout).toBe(60)
|
||||
expect(result.current.state.sseReadTimeout).toBe(600)
|
||||
expect(result.current.state.isDynamicRegistration).toBe(false)
|
||||
expect(result.current.state.clientID).toBe('client-123')
|
||||
expect(result.current.state.credentials).toBe('secret-456')
|
||||
})
|
||||
|
||||
it('should initialize headers from masked_headers', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.headers).toHaveLength(2)
|
||||
expect(result.current.state.headers[0].key).toBe('Authorization')
|
||||
expect(result.current.state.headers[0].value).toBe('***')
|
||||
expect(result.current.state.headers[1].key).toBe('X-Custom')
|
||||
expect(result.current.state.headers[1].value).toBe('value')
|
||||
})
|
||||
|
||||
it('should initialize emoji icon from data', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.appIcon.type).toBe('emoji')
|
||||
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀')
|
||||
expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('should store original server URL and ID', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.originalServerUrl).toBe('https://example.com/mcp')
|
||||
expect(result.current.originalServerID).toBe('test-server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode with string icon', () => {
|
||||
const mockDataWithImageIcon: ToolWithProvider = {
|
||||
id: 'test-id',
|
||||
name: 'Test',
|
||||
icon: 'https://example.com/files/abc123/file-preview/icon.png',
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
it('should initialize image icon from string URL', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon))
|
||||
|
||||
expect(result.current.state.appIcon.type).toBe('image')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should update url', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setUrl('https://new-url.com')
|
||||
})
|
||||
|
||||
expect(result.current.state.url).toBe('https://new-url.com')
|
||||
})
|
||||
|
||||
it('should update name', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setName('New Server Name')
|
||||
})
|
||||
|
||||
expect(result.current.state.name).toBe('New Server Name')
|
||||
})
|
||||
|
||||
it('should update serverIdentifier', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setServerIdentifier('new-server-id')
|
||||
})
|
||||
|
||||
expect(result.current.state.serverIdentifier).toBe('new-server-id')
|
||||
})
|
||||
|
||||
it('should update timeout', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setTimeout(120)
|
||||
})
|
||||
|
||||
expect(result.current.state.timeout).toBe(120)
|
||||
})
|
||||
|
||||
it('should update sseReadTimeout', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setSseReadTimeout(900)
|
||||
})
|
||||
|
||||
expect(result.current.state.sseReadTimeout).toBe(900)
|
||||
})
|
||||
|
||||
it('should update headers', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
const newHeaders = [{ id: '1', key: 'X-New', value: 'new-value' }]
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setHeaders(newHeaders)
|
||||
})
|
||||
|
||||
expect(result.current.state.headers).toEqual(newHeaders)
|
||||
})
|
||||
|
||||
it('should update authMethod', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setAuthMethod(MCPAuthMethod.headers)
|
||||
})
|
||||
|
||||
expect(result.current.state.authMethod).toBe(MCPAuthMethod.headers)
|
||||
})
|
||||
|
||||
it('should update isDynamicRegistration', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setIsDynamicRegistration(false)
|
||||
})
|
||||
|
||||
expect(result.current.state.isDynamicRegistration).toBe(false)
|
||||
})
|
||||
|
||||
it('should update clientID', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setClientID('new-client-id')
|
||||
})
|
||||
|
||||
expect(result.current.state.clientID).toBe('new-client-id')
|
||||
})
|
||||
|
||||
it('should update credentials', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setCredentials('new-secret')
|
||||
})
|
||||
|
||||
expect(result.current.state.credentials).toBe('new-secret')
|
||||
})
|
||||
|
||||
it('should update appIcon', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
const newIcon = { type: 'emoji' as const, icon: '🎉', background: '#00FF00' }
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setAppIcon(newIcon)
|
||||
})
|
||||
|
||||
expect(result.current.state.appIcon).toEqual(newIcon)
|
||||
})
|
||||
|
||||
it('should toggle showAppIconPicker', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
expect(result.current.state.showAppIconPicker).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setShowAppIconPicker(true)
|
||||
})
|
||||
|
||||
expect(result.current.state.showAppIconPicker).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset icon to default', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
// Change icon first
|
||||
act(() => {
|
||||
result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' })
|
||||
})
|
||||
|
||||
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉')
|
||||
|
||||
// Reset icon
|
||||
act(() => {
|
||||
result.current.actions.resetIcon()
|
||||
})
|
||||
|
||||
expect(result.current.state.appIcon).toEqual({
|
||||
type: 'emoji',
|
||||
icon: '🔗',
|
||||
background: '#6366F1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUrlBlur', () => {
|
||||
it('should not fetch icon in edit mode (when data is provided)', async () => {
|
||||
const mockData = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
} as unknown as ToolWithProvider
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com')
|
||||
})
|
||||
|
||||
// In edit mode, handleUrlBlur should return early
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
})
|
||||
|
||||
it('should not fetch icon for invalid URL', async () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('not-a-valid-url')
|
||||
})
|
||||
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle error when icon fetch fails with error code', async () => {
|
||||
const { uploadRemoteFileInfo } = await import('@/service/common')
|
||||
const mockError = {
|
||||
json: vi.fn().mockResolvedValue({ code: 'UPLOAD_ERROR' }),
|
||||
}
|
||||
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com/mcp')
|
||||
})
|
||||
|
||||
// Should have called console.error
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
// isFetchingIcon should be reset to false after error
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle error when icon fetch fails without error code', async () => {
|
||||
const { uploadRemoteFileInfo } = await import('@/service/common')
|
||||
const mockError = {
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com/mcp')
|
||||
})
|
||||
|
||||
// Should have called console.error
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
// isFetchingIcon should be reset to false after error
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should fetch icon successfully for valid URL in create mode', async () => {
|
||||
vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({
|
||||
id: 'file123',
|
||||
name: 'icon.png',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
url: 'https://example.com/files/file123/file-preview/icon.png',
|
||||
} as unknown as { id: string, name: string, size: number, mime_type: string, url: string })
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com/mcp')
|
||||
})
|
||||
|
||||
// Icon should be set to image type
|
||||
expect(result.current.state.appIcon.type).toBe('image')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png')
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
// Base mock data with required icon field
|
||||
const baseMockData = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
}
|
||||
|
||||
it('should handle undefined configuration', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.timeout).toBe(30)
|
||||
expect(result.current.state.sseReadTimeout).toBe(300)
|
||||
})
|
||||
|
||||
it('should handle undefined authentication', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.clientID).toBe('')
|
||||
expect(result.current.state.credentials).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined masked_headers', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.headers).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle undefined is_dynamic_registration (defaults to true)', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.isDynamicRegistration).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle string icon URL', () => {
|
||||
const mockData = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: 'https://example.com/icon.png',
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.appIcon.type).toBe('image')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
203
web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts
Normal file
203
web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts
Normal file
@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
import type { HeaderItem } from '../headers-input'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { getDomain } from 'tldts'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { MCPAuthMethod } from '@/app/components/tools/types'
|
||||
import { uploadRemoteFileInfo } from '@/service/common'
|
||||
|
||||
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
|
||||
|
||||
const extractFileId = (url: string) => {
|
||||
const match = url.match(/files\/(.+?)\/file-preview/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
const getIcon = (data?: ToolWithProvider): AppIconSelection => {
|
||||
if (!data)
|
||||
return DEFAULT_ICON as AppIconSelection
|
||||
if (typeof data.icon === 'string')
|
||||
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
|
||||
return {
|
||||
...data.icon,
|
||||
icon: data.icon.content,
|
||||
type: 'emoji',
|
||||
} as unknown as AppIconSelection
|
||||
}
|
||||
|
||||
const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => {
|
||||
return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))
|
||||
}
|
||||
|
||||
export const isValidUrl = (string: string) => {
|
||||
try {
|
||||
const url = new URL(string)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const isValidServerID = (str: string) => {
|
||||
return /^[a-z0-9_-]{1,24}$/.test(str)
|
||||
}
|
||||
|
||||
export type MCPModalFormState = {
|
||||
url: string
|
||||
name: string
|
||||
appIcon: AppIconSelection
|
||||
showAppIconPicker: boolean
|
||||
serverIdentifier: string
|
||||
timeout: number
|
||||
sseReadTimeout: number
|
||||
headers: HeaderItem[]
|
||||
isFetchingIcon: boolean
|
||||
authMethod: MCPAuthMethod
|
||||
isDynamicRegistration: boolean
|
||||
clientID: string
|
||||
credentials: string
|
||||
}
|
||||
|
||||
export type MCPModalFormActions = {
|
||||
setUrl: (url: string) => void
|
||||
setName: (name: string) => void
|
||||
setAppIcon: (icon: AppIconSelection) => void
|
||||
setShowAppIconPicker: (show: boolean) => void
|
||||
setServerIdentifier: (id: string) => void
|
||||
setTimeout: (timeout: number) => void
|
||||
setSseReadTimeout: (timeout: number) => void
|
||||
setHeaders: (headers: HeaderItem[]) => void
|
||||
setAuthMethod: (method: string) => void
|
||||
setIsDynamicRegistration: (value: boolean) => void
|
||||
setClientID: (id: string) => void
|
||||
setCredentials: (credentials: string) => void
|
||||
handleUrlBlur: (url: string) => Promise<void>
|
||||
resetIcon: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for MCP Modal form state management.
|
||||
*
|
||||
* Note: This hook uses a `formKey` (data ID or 'create') to reset form state when
|
||||
* switching between edit and create modes. All useState initializers read from `data`
|
||||
* directly, and the key change triggers a remount of the consumer component.
|
||||
*/
|
||||
export const useMCPModalForm = (data?: ToolWithProvider) => {
|
||||
const isCreate = !data
|
||||
const originalServerUrl = data?.server_url
|
||||
const originalServerID = data?.server_identifier
|
||||
|
||||
// Form key for resetting state - changes when data changes
|
||||
const formKey = useMemo(() => data?.id ?? 'create', [data?.id])
|
||||
|
||||
// Form state - initialized from data
|
||||
const [url, setUrl] = useState(() => data?.server_url || '')
|
||||
const [name, setName] = useState(() => data?.name || '')
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '')
|
||||
const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30)
|
||||
const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300)
|
||||
const [headers, setHeaders] = useState<HeaderItem[]>(() => getInitialHeaders(data))
|
||||
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
|
||||
const appIconRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auth state
|
||||
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
|
||||
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
|
||||
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
|
||||
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
|
||||
|
||||
const handleUrlBlur = useCallback(async (urlValue: string) => {
|
||||
if (data)
|
||||
return
|
||||
if (!isValidUrl(urlValue))
|
||||
return
|
||||
const domain = getDomain(urlValue)
|
||||
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
|
||||
setIsFetchingIcon(true)
|
||||
try {
|
||||
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
|
||||
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
|
||||
}
|
||||
catch (e) {
|
||||
let errorMessage = 'Failed to fetch remote icon'
|
||||
if (e instanceof Response) {
|
||||
try {
|
||||
const errorData = await e.json()
|
||||
if (errorData?.code)
|
||||
errorMessage = `Upload failed: ${errorData.code}`
|
||||
}
|
||||
catch {
|
||||
// Ignore JSON parsing errors
|
||||
}
|
||||
}
|
||||
else if (e instanceof Error) {
|
||||
errorMessage = e.message
|
||||
}
|
||||
console.error('Failed to fetch remote icon:', e)
|
||||
Toast.notify({ type: 'warning', message: errorMessage })
|
||||
}
|
||||
finally {
|
||||
setIsFetchingIcon(false)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const resetIcon = useCallback(() => {
|
||||
setAppIcon(getIcon(data))
|
||||
}, [data])
|
||||
|
||||
const handleAuthMethodChange = useCallback((value: string) => {
|
||||
setAuthMethod(value as MCPAuthMethod)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Key for form reset (use as React key on parent)
|
||||
formKey,
|
||||
|
||||
// Metadata
|
||||
isCreate,
|
||||
originalServerUrl,
|
||||
originalServerID,
|
||||
appIconRef,
|
||||
|
||||
// State
|
||||
state: {
|
||||
url,
|
||||
name,
|
||||
appIcon,
|
||||
showAppIconPicker,
|
||||
serverIdentifier,
|
||||
timeout,
|
||||
sseReadTimeout,
|
||||
headers,
|
||||
isFetchingIcon,
|
||||
authMethod,
|
||||
isDynamicRegistration,
|
||||
clientID,
|
||||
credentials,
|
||||
} satisfies MCPModalFormState,
|
||||
|
||||
// Actions
|
||||
actions: {
|
||||
setUrl,
|
||||
setName,
|
||||
setAppIcon,
|
||||
setShowAppIconPicker,
|
||||
setServerIdentifier,
|
||||
setTimeout: setMcpTimeout,
|
||||
setSseReadTimeout,
|
||||
setHeaders,
|
||||
setAuthMethod: handleAuthMethodChange,
|
||||
setIsDynamicRegistration,
|
||||
setClientID,
|
||||
setCredentials,
|
||||
handleUrlBlur,
|
||||
resetIcon,
|
||||
} satisfies MCPModalFormActions,
|
||||
}
|
||||
}
|
||||
451
web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts
Normal file
451
web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts
Normal file
@ -0,0 +1,451 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useMCPServiceCardState } from './use-mcp-service-card'
|
||||
|
||||
// Mutable mock data for MCP server detail
|
||||
let mockMCPServerDetailData: {
|
||||
id: string
|
||||
status: string
|
||||
server_code: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
} | undefined = {
|
||||
id: 'server-123',
|
||||
status: 'active',
|
||||
server_code: 'abc123',
|
||||
description: 'Test server',
|
||||
parameters: {},
|
||||
}
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useUpdateMCPServer: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useRefreshMCPServerCode: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
}),
|
||||
useMCPServerDetail: () => ({
|
||||
data: mockMCPServerDetailData,
|
||||
}),
|
||||
useInvalidateMCPServerDetail: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock workflow hook
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useAppWorkflow: (appId: string) => ({
|
||||
data: appId
|
||||
? {
|
||||
graph: {
|
||||
nodes: [
|
||||
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock apps service
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetail: vi.fn().mockResolvedValue({
|
||||
model_config: {
|
||||
updated_at: '2024-01-01',
|
||||
user_input_form: [],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useMCPServiceCardState', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial<AppSSO> => ({
|
||||
id: 'app-123',
|
||||
name: 'Test App',
|
||||
mode,
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
} as AppDetailResponse & Partial<AppSSO>)
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock data to default (published server)
|
||||
mockMCPServerDetailData = {
|
||||
id: 'server-123',
|
||||
status: 'active',
|
||||
server_code: 'abc123',
|
||||
description: 'Test server',
|
||||
parameters: {},
|
||||
}
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with correct default values for basic app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.serverPublished).toBe(true)
|
||||
expect(result.current.serverActivated).toBe(true)
|
||||
expect(result.current.showConfirmDelete).toBe(false)
|
||||
expect(result.current.showMCPServerModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize with correct values for workflow app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize with correct values for advanced chat app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server URL Generation', () => {
|
||||
it('should generate correct server URL when published', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Flags', () => {
|
||||
it('should have isCurrentWorkspaceManager as true', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isCurrentWorkspaceManager).toBe(true)
|
||||
})
|
||||
|
||||
it('should have toggleDisabled false when editor has permissions', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Toggle is not disabled when user has permissions and app is published
|
||||
expect(typeof result.current.toggleDisabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should have toggleDisabled true when triggerModeDisabled is true', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, true),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.toggleDisabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI State Actions', () => {
|
||||
it('should open confirm delete modal', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.showConfirmDelete).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete()
|
||||
})
|
||||
expect(result.current.showConfirmDelete).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirmDelete()
|
||||
})
|
||||
expect(result.current.showConfirmDelete).toBe(false)
|
||||
})
|
||||
|
||||
it('should open server modal', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.showMCPServerModal).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.openServerModal()
|
||||
})
|
||||
|
||||
expect(result.current.showMCPServerModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle server modal hide', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openServerModal()
|
||||
})
|
||||
expect(result.current.showMCPServerModal).toBe(true)
|
||||
|
||||
let hideResult: { shouldDeactivate: boolean } | undefined
|
||||
act(() => {
|
||||
hideResult = result.current.handleServerModalHide(false)
|
||||
})
|
||||
|
||||
expect(result.current.showMCPServerModal).toBe(false)
|
||||
expect(hideResult?.shouldDeactivate).toBe(true)
|
||||
})
|
||||
|
||||
it('should not deactivate when wasActivated is true', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
let hideResult: { shouldDeactivate: boolean } | undefined
|
||||
act(() => {
|
||||
hideResult = result.current.handleServerModalHide(true)
|
||||
})
|
||||
|
||||
expect(hideResult?.shouldDeactivate).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Handler Functions', () => {
|
||||
it('should have handleGenCode function', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.handleGenCode).toBe('function')
|
||||
})
|
||||
|
||||
it('should call handleGenCode and invalidate server detail', async () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleGenCode()
|
||||
})
|
||||
|
||||
// handleGenCode should complete without error
|
||||
expect(result.current.genLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should have handleStatusChange function', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.handleStatusChange).toBe('function')
|
||||
})
|
||||
|
||||
it('should have invalidateBasicAppConfig function', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
|
||||
})
|
||||
|
||||
it('should call invalidateBasicAppConfig', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Call the function - should not throw
|
||||
act(() => {
|
||||
result.current.invalidateBasicAppConfig()
|
||||
})
|
||||
|
||||
// Function should exist and be callable
|
||||
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Change', () => {
|
||||
it('should return activated state when status change succeeds', async () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
let statusResult: { activated: boolean } | undefined
|
||||
await act(async () => {
|
||||
statusResult = await result.current.handleStatusChange(true)
|
||||
})
|
||||
|
||||
expect(statusResult?.activated).toBe(true)
|
||||
})
|
||||
|
||||
it('should return deactivated state when disabling', async () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
let statusResult: { activated: boolean } | undefined
|
||||
await act(async () => {
|
||||
statusResult = await result.current.handleStatusChange(false)
|
||||
})
|
||||
|
||||
expect(statusResult?.activated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unpublished Server', () => {
|
||||
it('should open modal and return not activated when enabling unpublished server', async () => {
|
||||
// Set mock to return undefined (unpublished server)
|
||||
mockMCPServerDetailData = undefined
|
||||
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Verify server is not published
|
||||
expect(result.current.serverPublished).toBe(false)
|
||||
|
||||
let statusResult: { activated: boolean } | undefined
|
||||
await act(async () => {
|
||||
statusResult = await result.current.handleStatusChange(true)
|
||||
})
|
||||
|
||||
// Should open modal and return not activated
|
||||
expect(result.current.showMCPServerModal).toBe(true)
|
||||
expect(statusResult?.activated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should have genLoading state', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.genLoading).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should have isLoading state for basic app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Basic app doesn't need workflow, so isLoading should be false
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Detail Data', () => {
|
||||
it('should return detail data when available', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.detail).toBeDefined()
|
||||
expect(result.current.detail?.id).toBe('server-123')
|
||||
expect(result.current.detail?.status).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Latest Params', () => {
|
||||
it('should return latestParams for workflow app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current.latestParams)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return latestParams for basic app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current.latestParams)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
179
web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
Normal file
179
web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
Normal file
@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import {
|
||||
useInvalidateMCPServerDetail,
|
||||
useMCPServerDetail,
|
||||
useRefreshMCPServerCode,
|
||||
useUpdateMCPServer,
|
||||
} from '@/service/use-tools'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const BASIC_APP_CONFIG_KEY = 'basicAppConfig'
|
||||
|
||||
type AppInfo = AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
type BasicAppConfig = {
|
||||
updated_at?: string
|
||||
user_input_form?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export const useMCPServiceCardState = (
|
||||
appInfo: AppInfo,
|
||||
triggerModeDisabled: boolean,
|
||||
) => {
|
||||
const appId = appInfo.id
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// API hooks
|
||||
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
|
||||
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
|
||||
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
|
||||
|
||||
// Context
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
// UI state
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
|
||||
|
||||
// Derived app type values
|
||||
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
|
||||
const isBasicApp = !isAdvancedApp
|
||||
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
|
||||
|
||||
// Workflow data for advanced apps
|
||||
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
|
||||
|
||||
// Basic app config fetch using React Query
|
||||
const { data: basicAppConfig = {} } = useQuery<BasicAppConfig>({
|
||||
queryKey: [BASIC_APP_CONFIG_KEY, appId],
|
||||
queryFn: async () => {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||
return (res?.model_config as BasicAppConfig) || {}
|
||||
},
|
||||
enabled: isBasicApp && !!appId,
|
||||
})
|
||||
|
||||
// MCP server detail
|
||||
const { data: detail } = useMCPServerDetail(appId)
|
||||
const { id, status, server_code } = detail ?? {}
|
||||
|
||||
// Server state
|
||||
const serverPublished = !!id
|
||||
const serverActivated = status === 'active'
|
||||
const serverURL = serverPublished
|
||||
? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp`
|
||||
: '***********'
|
||||
|
||||
// App state checks
|
||||
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
|
||||
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
|
||||
const missingStartNode = isWorkflowApp && !hasStartNode
|
||||
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
|
||||
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
|
||||
const isMinimalState = appUnpublished || missingStartNode
|
||||
|
||||
// Basic app input form
|
||||
const basicAppInputForm = useMemo(() => {
|
||||
if (!isBasicApp || !basicAppConfig?.user_input_form)
|
||||
return []
|
||||
return (basicAppConfig.user_input_form as Array<Record<string, unknown>>).map((item) => {
|
||||
const type = Object.keys(item)[0]
|
||||
return {
|
||||
...(item[type] as object),
|
||||
type: type || 'text-input',
|
||||
}
|
||||
})
|
||||
}, [basicAppConfig?.user_input_form, isBasicApp])
|
||||
|
||||
// Latest params for modal
|
||||
const latestParams = useMemo(() => {
|
||||
if (isAdvancedApp) {
|
||||
if (!currentWorkflow?.graph)
|
||||
return []
|
||||
type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> }
|
||||
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined
|
||||
return startNode?.data.variables || []
|
||||
}
|
||||
return basicAppInputForm
|
||||
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
|
||||
|
||||
// Handlers
|
||||
const handleGenCode = useCallback(async () => {
|
||||
await refreshMCPServerCode(detail?.id || '')
|
||||
invalidateMCPServerDetail(appId)
|
||||
}, [refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId])
|
||||
|
||||
const handleStatusChange = useCallback(async (state: boolean) => {
|
||||
if (state && !serverPublished) {
|
||||
setShowMCPServerModal(true)
|
||||
return { activated: false }
|
||||
}
|
||||
|
||||
await updateMCPServer({
|
||||
appID: appId,
|
||||
id: id || '',
|
||||
description: detail?.description || '',
|
||||
parameters: detail?.parameters || {},
|
||||
status: state ? 'active' : 'inactive',
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
return { activated: state }
|
||||
}, [serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail])
|
||||
|
||||
const handleServerModalHide = useCallback((wasActivated: boolean) => {
|
||||
setShowMCPServerModal(false)
|
||||
// If server wasn't activated before opening modal, keep it deactivated
|
||||
return { shouldDeactivate: !wasActivated }
|
||||
}, [])
|
||||
|
||||
const openConfirmDelete = useCallback(() => setShowConfirmDelete(true), [])
|
||||
const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), [])
|
||||
const openServerModal = useCallback(() => setShowMCPServerModal(true), [])
|
||||
|
||||
const invalidateBasicAppConfig = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] })
|
||||
}, [queryClient, appId])
|
||||
|
||||
return {
|
||||
// Loading states
|
||||
genLoading,
|
||||
isLoading: isAdvancedApp ? !currentWorkflow : false,
|
||||
|
||||
// Server state
|
||||
serverPublished,
|
||||
serverActivated,
|
||||
serverURL,
|
||||
detail,
|
||||
|
||||
// Permission & validation flags
|
||||
isCurrentWorkspaceManager,
|
||||
toggleDisabled,
|
||||
isMinimalState,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
|
||||
// UI state
|
||||
showConfirmDelete,
|
||||
showMCPServerModal,
|
||||
|
||||
// Data
|
||||
latestParams,
|
||||
|
||||
// Handlers
|
||||
handleGenCode,
|
||||
handleStatusChange,
|
||||
handleServerModalHide,
|
||||
openConfirmDelete,
|
||||
closeConfirmDelete,
|
||||
openServerModal,
|
||||
invalidateBasicAppConfig,
|
||||
}
|
||||
}
|
||||
361
web/app/components/tools/mcp/mcp-server-modal.spec.tsx
Normal file
361
web/app/components/tools/mcp/mcp-server-modal.spec.tsx
Normal file
@ -0,0 +1,361 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MCPServerDetail } from '@/app/components/tools/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCPServerModal from './mcp-server-modal'
|
||||
|
||||
// Mock the services
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useCreateMCPServer: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateMCPServer: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
|
||||
isPending: false,
|
||||
}),
|
||||
useInvalidateMCPServerDetail: () => vi.fn(),
|
||||
}))
|
||||
|
||||
describe('MCPServerModal', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
appID: 'app-123',
|
||||
show: true,
|
||||
onHide: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add title when no data is provided', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit title when data is provided', () => {
|
||||
const mockData = {
|
||||
id: 'server-1',
|
||||
description: 'Existing description',
|
||||
parameters: {},
|
||||
} as unknown as MCPServerDetail
|
||||
|
||||
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.editTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description label', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render required indicator', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('*')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description textarea', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirm button in add mode', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.confirm')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save button in edit mode', () => {
|
||||
const mockData = {
|
||||
id: 'server-1',
|
||||
description: 'Existing description',
|
||||
parameters: {},
|
||||
} as unknown as MCPServerDetail
|
||||
|
||||
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
const closeButton = document.querySelector('.cursor-pointer svg')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parameters Section', () => {
|
||||
it('should not render parameters section when no latestParams', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.queryByText('tools.mcp.server.modal.parameters')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters section when latestParams is provided', () => {
|
||||
const latestParams = [
|
||||
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
|
||||
]
|
||||
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.parameters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters tip', () => {
|
||||
const latestParams = [
|
||||
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
|
||||
]
|
||||
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.server.modal.parametersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameter items', () => {
|
||||
const latestParams = [
|
||||
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
|
||||
{ variable: 'param2', label: 'Parameter 2', type: 'number' },
|
||||
]
|
||||
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Parameter 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update description when typing', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'New description' } })
|
||||
|
||||
expect(textarea).toHaveValue('New description')
|
||||
})
|
||||
|
||||
it('should call onHide when cancel button is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onHide when close icon is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
const closeButton = document.querySelector('.cursor-pointer')
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should disable confirm button when description is empty', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
|
||||
expect(confirmButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable confirm button when description is filled', () => {
|
||||
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'Valid description' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
|
||||
expect(confirmButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
const mockData = {
|
||||
id: 'server-1',
|
||||
description: 'Existing description',
|
||||
parameters: { param1: 'existing value' },
|
||||
} as unknown as MCPServerDetail
|
||||
|
||||
it('should populate description with existing value', () => {
|
||||
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
expect(textarea).toHaveValue('Existing description')
|
||||
})
|
||||
|
||||
it('should populate parameters with existing values', () => {
|
||||
const latestParams = [
|
||||
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
|
||||
]
|
||||
render(
|
||||
<MCPServerModal {...defaultProps} data={mockData} latestParams={latestParams} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(paramInput).toHaveValue('existing value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should submit form with description', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'Test description' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('With App Info', () => {
|
||||
it('should use appInfo description as default when no data', () => {
|
||||
const appInfo = { description: 'App default description' }
|
||||
render(<MCPServerModal {...defaultProps} appInfo={appInfo} />, { wrapper: createWrapper() })
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
expect(textarea).toHaveValue('App default description')
|
||||
})
|
||||
|
||||
it('should prefer data description over appInfo description', () => {
|
||||
const appInfo = { description: 'App default description' }
|
||||
const mockData = {
|
||||
id: 'server-1',
|
||||
description: 'Data description',
|
||||
parameters: {},
|
||||
} as unknown as MCPServerDetail
|
||||
|
||||
render(
|
||||
<MCPServerModal {...defaultProps} data={mockData} appInfo={appInfo} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
expect(textarea).toHaveValue('Data description')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Not Shown State', () => {
|
||||
it('should not render modal content when show is false', () => {
|
||||
render(<MCPServerModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
|
||||
expect(screen.queryByText('tools.mcp.server.modal.addTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Mode Submission', () => {
|
||||
it('should submit update when data is provided', async () => {
|
||||
const onHide = vi.fn()
|
||||
const mockData = {
|
||||
id: 'server-1',
|
||||
description: 'Existing description',
|
||||
parameters: { param1: 'value1' },
|
||||
} as unknown as MCPServerDetail
|
||||
|
||||
render(
|
||||
<MCPServerModal {...defaultProps} data={mockData} onHide={onHide} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Change description
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'Updated description' } })
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByText('tools.mcp.modal.save')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parameter Handling', () => {
|
||||
it('should update parameter value when changed', async () => {
|
||||
const latestParams = [
|
||||
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
|
||||
{ variable: 'param2', label: 'Parameter 2', type: 'string' },
|
||||
]
|
||||
|
||||
render(
|
||||
<MCPServerModal {...defaultProps} latestParams={latestParams} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Fill description first
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'Test description' } })
|
||||
|
||||
// Get all parameter inputs
|
||||
const paramInputs = screen.getAllByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
|
||||
// Change the first parameter value
|
||||
fireEvent.change(paramInputs[0], { target: { value: 'new param value' } })
|
||||
|
||||
expect(paramInputs[0]).toHaveValue('new param value')
|
||||
})
|
||||
|
||||
it('should submit with parameter values', async () => {
|
||||
const onHide = vi.fn()
|
||||
const latestParams = [
|
||||
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
|
||||
]
|
||||
|
||||
render(
|
||||
<MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Fill description
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'Test description' } })
|
||||
|
||||
// Fill parameter
|
||||
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(paramInput, { target: { value: 'param value' } })
|
||||
|
||||
// Submit
|
||||
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty description submission', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: '' } })
|
||||
|
||||
// Button should be disabled
|
||||
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
|
||||
expect(confirmButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
165
web/app/components/tools/mcp/mcp-server-param-item.spec.tsx
Normal file
165
web/app/components/tools/mcp/mcp-server-param-item.spec.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCPServerParamItem from './mcp-server-param-item'
|
||||
|
||||
describe('MCPServerParamItem', () => {
|
||||
const defaultProps = {
|
||||
data: {
|
||||
label: 'Test Label',
|
||||
variable: 'test_variable',
|
||||
type: 'string',
|
||||
},
|
||||
value: '',
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display label', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display variable name', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('test_variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display type', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display separator dot', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textarea with placeholder', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Display', () => {
|
||||
it('should display empty value by default', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should display provided value', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} value="test value" />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toHaveValue('test value')
|
||||
})
|
||||
|
||||
it('should display long text value', () => {
|
||||
const longValue = 'This is a very long text value that might span multiple lines'
|
||||
render(<MCPServerParamItem {...defaultProps} value={longValue} />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toHaveValue(longValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when text is entered', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'new value' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should call onChange with empty string when cleared', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} value="existing" onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: '' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should handle multiple changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'first' } })
|
||||
fireEvent.change(textarea, { target: { value: 'second' } })
|
||||
fireEvent.change(textarea, { target: { value: 'third' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(3)
|
||||
expect(onChange).toHaveBeenLastCalledWith('third')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Different Data Types', () => {
|
||||
it('should display number type', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Count', variable: 'count', type: 'number' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('number')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display boolean type', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Enabled', variable: 'enabled', type: 'boolean' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('boolean')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display array type', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Items', variable: 'items', type: 'array' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('array')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in label', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Test <Label> & "Special"', variable: 'test', type: 'string' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('Test <Label> & "Special"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty data object properties', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: '', variable: '', type: '' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
// Should render without crashing
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle unicode characters in value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: '你好世界 🌍' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('你好世界 🌍')
|
||||
})
|
||||
})
|
||||
})
|
||||
1041
web/app/components/tools/mcp/mcp-service-card.spec.tsx
Normal file
1041
web/app/components/tools/mcp/mcp-service-card.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,168 +1,234 @@
|
||||
'use client'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import {
|
||||
Mcp,
|
||||
} from '@/app/components/base/icons/src/vender/other'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import {
|
||||
useInvalidateMCPServerDetail,
|
||||
useMCPServerDetail,
|
||||
useRefreshMCPServerCode,
|
||||
useUpdateMCPServer,
|
||||
} from '@/service/use-tools'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
|
||||
|
||||
export type IAppCardProps = {
|
||||
appInfo: AppDetailResponse & Partial<AppSSO>
|
||||
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
|
||||
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
|
||||
// Sub-components
|
||||
type StatusIndicatorProps = {
|
||||
serverActivated: boolean
|
||||
}
|
||||
|
||||
function MCPServiceCard({
|
||||
const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Indicator color={serverActivated ? 'green' : 'yellow'} />
|
||||
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
|
||||
{serverActivated
|
||||
? t('overview.status.running', { ns: 'appOverview' })
|
||||
: t('overview.status.disable', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ServerURLSectionProps = {
|
||||
serverURL: string
|
||||
serverPublished: boolean
|
||||
isCurrentWorkspaceManager: boolean
|
||||
genLoading: boolean
|
||||
onRegenerate: () => void
|
||||
}
|
||||
|
||||
const ServerURLSection: FC<ServerURLSectionProps> = ({
|
||||
serverURL,
|
||||
serverPublished,
|
||||
isCurrentWorkspaceManager,
|
||||
genLoading,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-center self-stretch">
|
||||
<div className="system-xs-medium pb-1 text-text-tertiary">
|
||||
{t('mcp.server.url', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
|
||||
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
|
||||
{serverURL}
|
||||
</div>
|
||||
</div>
|
||||
{serverPublished && (
|
||||
<>
|
||||
<CopyFeedback content={serverURL} className="!size-6" />
|
||||
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
|
||||
{isCurrentWorkspaceManager && (
|
||||
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TriggerModeOverlayProps = {
|
||||
triggerModeMessage: ReactNode
|
||||
}
|
||||
|
||||
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
|
||||
if (triggerModeMessage) {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={triggerModeMessage}
|
||||
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
|
||||
position="right"
|
||||
>
|
||||
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
// Helper function for tooltip content
|
||||
type TooltipContentParams = {
|
||||
toggleDisabled: boolean
|
||||
appUnpublished: boolean
|
||||
missingStartNode: boolean
|
||||
triggerModeMessage: ReactNode
|
||||
t: TFunction
|
||||
docLink: ReturnType<typeof useDocLink>
|
||||
}
|
||||
|
||||
function getTooltipContent({
|
||||
toggleDisabled,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
triggerModeMessage,
|
||||
t,
|
||||
docLink,
|
||||
}: TooltipContentParams): ReactNode {
|
||||
if (!toggleDisabled)
|
||||
return ''
|
||||
|
||||
if (appUnpublished)
|
||||
return t('mcp.server.publishTip', { ns: 'tools' })
|
||||
|
||||
if (missingStartNode) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 text-xs font-normal text-text-secondary">
|
||||
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
|
||||
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
|
||||
>
|
||||
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return triggerModeMessage || ''
|
||||
}
|
||||
|
||||
// Main component
|
||||
export type IAppCardProps = {
|
||||
appInfo: AppDetailResponse & Partial<AppSSO>
|
||||
triggerModeDisabled?: boolean
|
||||
triggerModeMessage?: ReactNode
|
||||
}
|
||||
|
||||
const MCPServiceCard: FC<IAppCardProps> = ({
|
||||
appInfo,
|
||||
triggerModeDisabled = false,
|
||||
triggerModeMessage = '',
|
||||
}: IAppCardProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const appId = appInfo.id
|
||||
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
|
||||
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
|
||||
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
|
||||
|
||||
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
|
||||
const isBasicApp = !isAdvancedApp
|
||||
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
|
||||
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
|
||||
const basicAppInputForm = useMemo(() => {
|
||||
if (!isBasicApp || !basicAppConfig?.user_input_form)
|
||||
return []
|
||||
return basicAppConfig.user_input_form.map((item: any) => {
|
||||
const type = Object.keys(item)[0]
|
||||
return {
|
||||
...item[type],
|
||||
type: type || 'text-input',
|
||||
}
|
||||
})
|
||||
}, [basicAppConfig.user_input_form, isBasicApp])
|
||||
useEffect(() => {
|
||||
if (isBasicApp && appId) {
|
||||
(async () => {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||
setBasicAppConfig(res?.model_config || {})
|
||||
})()
|
||||
}
|
||||
}, [appId, isBasicApp])
|
||||
const { data: detail } = useMCPServerDetail(appId)
|
||||
const { id, status, server_code } = detail ?? {}
|
||||
const {
|
||||
genLoading,
|
||||
isLoading,
|
||||
serverPublished,
|
||||
serverActivated,
|
||||
serverURL,
|
||||
detail,
|
||||
isCurrentWorkspaceManager,
|
||||
toggleDisabled,
|
||||
isMinimalState,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
showConfirmDelete,
|
||||
showMCPServerModal,
|
||||
latestParams,
|
||||
handleGenCode,
|
||||
handleStatusChange,
|
||||
handleServerModalHide,
|
||||
openConfirmDelete,
|
||||
closeConfirmDelete,
|
||||
openServerModal,
|
||||
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
|
||||
|
||||
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
|
||||
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
|
||||
const serverPublished = !!id
|
||||
const serverActivated = status === 'active'
|
||||
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
|
||||
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
|
||||
const missingStartNode = isWorkflowApp && !hasStartNode
|
||||
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
|
||||
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
|
||||
const isMinimalState = appUnpublished || missingStartNode
|
||||
|
||||
const [activated, setActivated] = useState(serverActivated)
|
||||
|
||||
const latestParams = useMemo(() => {
|
||||
if (isAdvancedApp) {
|
||||
if (!currentWorkflow?.graph)
|
||||
return []
|
||||
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
|
||||
return startNode?.data.variables as any[] || []
|
||||
}
|
||||
return basicAppInputForm
|
||||
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
|
||||
|
||||
const onGenCode = async () => {
|
||||
await refreshMCPServerCode(detail?.id || '')
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
// Pending status for optimistic updates (null means use server state)
|
||||
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
|
||||
const activated = pendingStatus ?? serverActivated
|
||||
|
||||
const onChangeStatus = async (state: boolean) => {
|
||||
setActivated(state)
|
||||
if (state) {
|
||||
if (!serverPublished) {
|
||||
setShowMCPServerModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
await updateMCPServer({
|
||||
appID: appId,
|
||||
id: id || '',
|
||||
description: detail?.description || '',
|
||||
parameters: detail?.parameters || {},
|
||||
status: 'active',
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
else {
|
||||
await updateMCPServer({
|
||||
appID: appId,
|
||||
id: id || '',
|
||||
description: detail?.description || '',
|
||||
parameters: detail?.parameters || {},
|
||||
status: 'inactive',
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
setPendingStatus(state)
|
||||
const result = await handleStatusChange(state)
|
||||
if (!result.activated && state) {
|
||||
// Server modal was opened instead, clear pending status
|
||||
setPendingStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerModalHide = () => {
|
||||
setShowMCPServerModal(false)
|
||||
if (!serverActivated)
|
||||
setActivated(false)
|
||||
const onServerModalHide = () => {
|
||||
handleServerModalHide(serverActivated)
|
||||
// Clear pending status when modal closes to sync with server state
|
||||
setPendingStatus(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setActivated(serverActivated)
|
||||
}, [serverActivated])
|
||||
const onConfirmRegenerate = () => {
|
||||
handleGenCode()
|
||||
closeConfirmDelete()
|
||||
}
|
||||
|
||||
if (!currentWorkflow && isAdvancedApp)
|
||||
if (isLoading)
|
||||
return null
|
||||
|
||||
const tooltipContent = getTooltipContent({
|
||||
toggleDisabled,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
triggerModeMessage,
|
||||
t,
|
||||
docLink,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
|
||||
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
|
||||
{triggerModeDisabled && (
|
||||
triggerModeMessage
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={triggerModeMessage}
|
||||
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
|
||||
position="right"
|
||||
>
|
||||
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
</Tooltip>
|
||||
)
|
||||
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
<TriggerModeOverlay triggerModeMessage={triggerModeMessage} />
|
||||
)}
|
||||
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
|
||||
<div className="flex w-full items-center gap-3 self-stretch">
|
||||
@ -176,40 +242,9 @@ function MCPServiceCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Indicator color={serverActivated ? 'green' : 'yellow'} />
|
||||
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
|
||||
{serverActivated
|
||||
? t('overview.status.running', { ns: 'appOverview' })
|
||||
: t('overview.status.disable', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</div>
|
||||
<StatusIndicator serverActivated={serverActivated} />
|
||||
<Tooltip
|
||||
popupContent={
|
||||
toggleDisabled
|
||||
? (
|
||||
appUnpublished
|
||||
? (
|
||||
t('mcp.server.publishTip', { ns: 'tools' })
|
||||
)
|
||||
: missingStartNode
|
||||
? (
|
||||
<>
|
||||
<div className="mb-1 text-xs font-normal text-text-secondary">
|
||||
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
|
||||
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
|
||||
>
|
||||
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: triggerModeMessage || ''
|
||||
)
|
||||
: ''
|
||||
}
|
||||
popupContent={tooltipContent}
|
||||
position="right"
|
||||
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
|
||||
offset={24}
|
||||
@ -220,39 +255,13 @@ function MCPServiceCard({
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isMinimalState && (
|
||||
<div className="flex flex-col items-start justify-center self-stretch">
|
||||
<div className="system-xs-medium pb-1 text-text-tertiary">
|
||||
{t('mcp.server.url', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
|
||||
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
|
||||
{serverURL}
|
||||
</div>
|
||||
</div>
|
||||
{serverPublished && (
|
||||
<>
|
||||
<CopyFeedback
|
||||
content={serverURL}
|
||||
className="!size-6"
|
||||
/>
|
||||
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
|
||||
{isCurrentWorkspaceManager && (
|
||||
<Tooltip
|
||||
popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={() => setShowConfirmDelete(true)}
|
||||
>
|
||||
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ServerURLSection
|
||||
serverURL={serverURL}
|
||||
serverPublished={serverPublished}
|
||||
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
|
||||
genLoading={genLoading}
|
||||
onRegenerate={openConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isMinimalState && (
|
||||
@ -261,40 +270,39 @@ function MCPServiceCard({
|
||||
disabled={toggleDisabled}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setShowMCPServerModal(true)}
|
||||
onClick={openServerModal}
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-center gap-[1px]">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}</div>
|
||||
<div className="system-xs-medium px-[3px] text-text-tertiary">
|
||||
{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMCPServerModal && (
|
||||
<MCPServerModal
|
||||
show={showMCPServerModal}
|
||||
appID={appId}
|
||||
data={serverPublished ? detail : undefined}
|
||||
latestParams={latestParams}
|
||||
onHide={handleServerModalHide}
|
||||
onHide={onServerModalHide}
|
||||
appInfo={appInfo}
|
||||
/>
|
||||
)}
|
||||
{/* button copy link/ button regenerate */}
|
||||
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
type="warning"
|
||||
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
|
||||
content={t('mcp.server.reGen', { ns: 'tools' })}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={() => {
|
||||
onGenCode()
|
||||
setShowConfirmDelete(false)
|
||||
}}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
onConfirm={onConfirmRegenerate}
|
||||
onCancel={closeConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
745
web/app/components/tools/mcp/modal.spec.tsx
Normal file
745
web/app/components/tools/mcp/modal.spec.tsx
Normal file
@ -0,0 +1,745 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCPModal from './modal'
|
||||
|
||||
// Mock the service API
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
|
||||
}))
|
||||
|
||||
// Mock the AppIconPicker component
|
||||
type IconPayload = {
|
||||
type: string
|
||||
icon: string
|
||||
background: string
|
||||
}
|
||||
|
||||
type AppIconPickerProps = {
|
||||
onSelect: (payload: IconPayload) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
default: ({ onSelect, onClose }: AppIconPickerProps) => (
|
||||
<div data-testid="app-icon-picker">
|
||||
<button data-testid="select-emoji-btn" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#FF0000' })}>
|
||||
Select Emoji
|
||||
</button>
|
||||
<button data-testid="close-picker-btn" onClick={onClose}>
|
||||
Close Picker
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock the plugins service to avoid React Query issues from TabSlider
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { pages: [] },
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('MCPModal', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
show: true,
|
||||
onConfirm: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when show is false', () => {
|
||||
render(<MCPModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
|
||||
expect(screen.queryByText('tools.mcp.modal.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create title when no data is provided', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit title when data is provided', () => {
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Test Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.editTitle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Fields', () => {
|
||||
it('should render server URL input', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.serverUrl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name input', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render server identifier input', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.serverIdentifier')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render auth method tabs', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.authentication')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.configurations')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update URL input value', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
fireEvent.change(urlInput, { target: { value: 'https://test.com/mcp' } })
|
||||
|
||||
expect(urlInput).toHaveValue('https://test.com/mcp')
|
||||
})
|
||||
|
||||
it('should update name input value', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'My Server' } })
|
||||
|
||||
expect(nameInput).toHaveValue('My Server')
|
||||
})
|
||||
|
||||
it('should update server identifier input value', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
fireEvent.change(identifierInput, { target: { value: 'my-server' } })
|
||||
|
||||
expect(identifierInput).toHaveValue('my-server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should show authentication section by default', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to headers section when clicked', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const headersTab = screen.getByText('tools.mcp.modal.headers')
|
||||
fireEvent.click(headersTab)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to configurations section when clicked', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const configTab = screen.getByText('tools.mcp.modal.configurations')
|
||||
fireEvent.click(configTab)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Action Buttons', () => {
|
||||
it('should render confirm button', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.confirm')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save button in edit mode', () => {
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Test',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onHide when cancel is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onHide when close icon is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the close button by its parent div with cursor-pointer class
|
||||
const closeButtons = document.querySelectorAll('.cursor-pointer')
|
||||
const closeButton = Array.from(closeButtons).find(el =>
|
||||
el.querySelector('svg'),
|
||||
)
|
||||
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have confirm button disabled when form is empty', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
expect(confirmButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable confirm button when required fields are filled', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill required fields
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
expect(confirmButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call onConfirm with correct data when form is submitted', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill required fields
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Test Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onConfirm with invalid URL', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill fields with invalid URL
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'not-a-valid-url' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Wait a bit and verify onConfirm was not called
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onConfirm with invalid server identifier', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill fields with invalid server identifier
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'Invalid Server ID!' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Wait a bit and verify onConfirm was not called
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Existing Server',
|
||||
server_url: 'https://existing.com/mcp',
|
||||
server_identifier: 'existing-server',
|
||||
icon: { content: '🚀', background: '#FF0000' },
|
||||
configuration: {
|
||||
timeout: 60,
|
||||
sse_read_timeout: 600,
|
||||
},
|
||||
masked_headers: {
|
||||
Authorization: '***',
|
||||
},
|
||||
is_dynamic_registration: false,
|
||||
authentication: {
|
||||
client_id: 'client-123',
|
||||
client_secret: 'secret-456',
|
||||
},
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
it('should populate form with existing data', () => {
|
||||
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByDisplayValue('https://existing.com/mcp')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Existing Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('existing-server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when URL is changed', () => {
|
||||
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
|
||||
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
|
||||
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
|
||||
|
||||
expect(screen.getByText('tools.mcp.modal.serverUrlWarning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when server identifier is changed', () => {
|
||||
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
|
||||
const identifierInput = screen.getByDisplayValue('existing-server')
|
||||
fireEvent.change(identifierInput, { target: { value: 'new-server' } })
|
||||
|
||||
expect(screen.getByText('tools.mcp.modal.serverIdentifierWarning')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Key Reset', () => {
|
||||
it('should reset form when switching from create to edit mode', () => {
|
||||
const { rerender } = render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill some data in create mode
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'New Server' } })
|
||||
|
||||
// Switch to edit mode with different data
|
||||
const mockData = {
|
||||
id: 'edit-id',
|
||||
name: 'Edit Server',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
rerender(<MCPModal {...defaultProps} data={mockData} />)
|
||||
|
||||
// Should show edit mode data
|
||||
expect(screen.getByDisplayValue('Edit Server')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Blur Handler', () => {
|
||||
it('should trigger URL blur handler when URL input loses focus', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
fireEvent.change(urlInput, { target: { value: ' https://test.com/mcp ' } })
|
||||
fireEvent.blur(urlInput)
|
||||
|
||||
// The blur handler trims the value
|
||||
expect(urlInput).toHaveValue(' https://test.com/mcp ')
|
||||
})
|
||||
|
||||
it('should handle URL blur with empty value', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
fireEvent.change(urlInput, { target: { value: '' } })
|
||||
fireEvent.blur(urlInput)
|
||||
|
||||
expect(urlInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Icon', () => {
|
||||
it('should render app icon with default emoji', () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// The app icon should be rendered
|
||||
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
|
||||
expect(appIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render app icon in edit mode with custom icon', () => {
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Test Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
icon: { content: '🚀', background: '#FF0000' },
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
|
||||
|
||||
// The app icon should be rendered
|
||||
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
|
||||
expect(appIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission with Headers', () => {
|
||||
it('should submit form with headers data', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill required fields
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
// Switch to headers tab and add a header
|
||||
const headersTab = screen.getByText('tools.mcp.modal.headers')
|
||||
fireEvent.click(headersTab)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Test Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should submit with authentication data', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill required fields
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
// Submit form
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authentication: expect.objectContaining({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should format headers correctly when submitting with header keys', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Test Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
masked_headers: {
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Custom': 'value',
|
||||
},
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Switch to headers tab
|
||||
const headersTab = screen.getByText('tools.mcp.modal.headers')
|
||||
fireEvent.click(headersTab)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByText('tools.mcp.modal.save')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode Submission', () => {
|
||||
it('should send hidden URL when URL is unchanged in edit mode', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Existing Server',
|
||||
server_url: 'https://existing.com/mcp',
|
||||
server_identifier: 'existing-server',
|
||||
icon: { content: '🚀', background: '#FF0000' },
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Don't change the URL, just submit
|
||||
const saveButton = screen.getByText('tools.mcp.modal.save')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
server_url: '[__HIDDEN__]',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should send new URL when URL is changed in edit mode', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
const mockData = {
|
||||
id: 'test-id',
|
||||
name: 'Existing Server',
|
||||
server_url: 'https://existing.com/mcp',
|
||||
server_identifier: 'existing-server',
|
||||
icon: { content: '🚀', background: '#FF0000' },
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Change the URL
|
||||
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
|
||||
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
|
||||
|
||||
const saveButton = screen.getByText('tools.mcp.modal.save')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
server_url: 'https://new.com/mcp',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configuration Section', () => {
|
||||
it('should submit with default timeout values', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill required fields
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configuration: expect.objectContaining({
|
||||
timeout: 30,
|
||||
sse_read_timeout: 300,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should submit with custom timeout values', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Fill required fields
|
||||
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
|
||||
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
|
||||
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
|
||||
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
|
||||
|
||||
// Switch to configurations tab
|
||||
const configTab = screen.getByText('tools.mcp.modal.configurations')
|
||||
fireEvent.click(configTab)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic Registration', () => {
|
||||
it('should toggle dynamic registration', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the switch for dynamic registration
|
||||
const switchElements = screen.getAllByRole('switch')
|
||||
expect(switchElements.length).toBeGreaterThan(0)
|
||||
|
||||
// Click the first switch (dynamic registration)
|
||||
fireEvent.click(switchElements[0])
|
||||
|
||||
// The switch should toggle
|
||||
expect(switchElements[0]).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Icon Picker Interactions', () => {
|
||||
it('should open app icon picker when app icon is clicked', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the app icon container with cursor-pointer and rounded-2xl classes
|
||||
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
|
||||
|
||||
if (appIconContainer) {
|
||||
fireEvent.click(appIconContainer)
|
||||
|
||||
// The mocked AppIconPicker should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should close app icon picker and update icon when selecting an icon', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the icon picker
|
||||
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
|
||||
|
||||
if (appIconContainer) {
|
||||
fireEvent.click(appIconContainer)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the select emoji button
|
||||
const selectBtn = screen.getByTestId('select-emoji-btn')
|
||||
fireEvent.click(selectBtn)
|
||||
|
||||
// The picker should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should close app icon picker and reset icon when close button is clicked', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the icon picker
|
||||
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
|
||||
|
||||
if (appIconContainer) {
|
||||
fireEvent.click(appIconContainer)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the close button
|
||||
const closeBtn = screen.getByTestId('close-picker-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
// The picker should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,429 +1,298 @@
|
||||
'use client'
|
||||
import type { HeaderItem } from './headers-input'
|
||||
import type { FC } from 'react'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { RiCloseLine, RiEditLine } from '@remixicon/react'
|
||||
import { useHover } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getDomain } from 'tldts'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import TabSlider from '@/app/components/base/tab-slider'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { MCPAuthMethod } from '@/app/components/tools/types'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { uploadRemoteFileInfo } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
|
||||
import HeadersInput from './headers-input'
|
||||
import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form'
|
||||
import AuthenticationSection from './sections/authentication-section'
|
||||
import ConfigurationsSection from './sections/configurations-section'
|
||||
import HeadersSection from './sections/headers-section'
|
||||
|
||||
export type MCPModalConfirmPayload = {
|
||||
name: string
|
||||
server_url: string
|
||||
icon_type: AppIconType
|
||||
icon: string
|
||||
icon_background?: string | null
|
||||
server_identifier: string
|
||||
headers?: Record<string, string>
|
||||
is_dynamic_registration?: boolean
|
||||
authentication?: {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
grant_type?: string
|
||||
}
|
||||
configuration: {
|
||||
timeout: number
|
||||
sse_read_timeout: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DuplicateAppModalProps = {
|
||||
data?: ToolWithProvider
|
||||
show: boolean
|
||||
onConfirm: (info: {
|
||||
name: string
|
||||
server_url: string
|
||||
icon_type: AppIconType
|
||||
icon: string
|
||||
icon_background?: string | null
|
||||
server_identifier: string
|
||||
headers?: Record<string, string>
|
||||
is_dynamic_registration?: boolean
|
||||
authentication?: {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
grant_type?: string
|
||||
}
|
||||
configuration: {
|
||||
timeout: number
|
||||
sse_read_timeout: number
|
||||
}
|
||||
}) => void
|
||||
onConfirm: (info: MCPModalConfirmPayload) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
|
||||
const extractFileId = (url: string) => {
|
||||
const match = url.match(/files\/(.+?)\/file-preview/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
const getIcon = (data?: ToolWithProvider) => {
|
||||
if (!data)
|
||||
return DEFAULT_ICON as AppIconSelection
|
||||
if (typeof data.icon === 'string')
|
||||
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
|
||||
return {
|
||||
...data.icon,
|
||||
icon: data.icon.content,
|
||||
type: 'emoji',
|
||||
} as unknown as AppIconSelection
|
||||
type MCPModalContentProps = {
|
||||
data?: ToolWithProvider
|
||||
onConfirm: (info: MCPModalConfirmPayload) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
const MCPModal = ({
|
||||
const MCPModalContent: FC<MCPModalContentProps> = ({
|
||||
data,
|
||||
show,
|
||||
onConfirm,
|
||||
onHide,
|
||||
}: DuplicateAppModalProps) => {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isCreate = !data
|
||||
|
||||
const {
|
||||
isCreate,
|
||||
originalServerUrl,
|
||||
originalServerID,
|
||||
appIconRef,
|
||||
state,
|
||||
actions,
|
||||
} = useMCPModalForm(data)
|
||||
|
||||
const isHovering = useHover(appIconRef)
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
text: t('mcp.modal.authentication', { ns: 'tools' }),
|
||||
value: MCPAuthMethod.authentication,
|
||||
},
|
||||
{
|
||||
text: t('mcp.modal.headers', { ns: 'tools' }),
|
||||
value: MCPAuthMethod.headers,
|
||||
},
|
||||
{
|
||||
text: t('mcp.modal.configurations', { ns: 'tools' }),
|
||||
value: MCPAuthMethod.configurations,
|
||||
},
|
||||
{ text: t('mcp.modal.authentication', { ns: 'tools' }), value: MCPAuthMethod.authentication },
|
||||
{ text: t('mcp.modal.headers', { ns: 'tools' }), value: MCPAuthMethod.headers },
|
||||
{ text: t('mcp.modal.configurations', { ns: 'tools' }), value: MCPAuthMethod.configurations },
|
||||
]
|
||||
const originalServerUrl = data?.server_url
|
||||
const originalServerID = data?.server_identifier
|
||||
const [url, setUrl] = React.useState(data?.server_url || '')
|
||||
const [name, setName] = React.useState(data?.name || '')
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
|
||||
const [timeout, setMcpTimeout] = React.useState(data?.configuration?.timeout || 30)
|
||||
const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.configuration?.sse_read_timeout || 300)
|
||||
const [headers, setHeaders] = React.useState<HeaderItem[]>(
|
||||
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
|
||||
)
|
||||
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
|
||||
const appIconRef = useRef<HTMLDivElement>(null)
|
||||
const isHovering = useHover(appIconRef)
|
||||
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
|
||||
const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration)
|
||||
const [clientID, setClientID] = useState(data?.authentication?.client_id || '')
|
||||
const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '')
|
||||
|
||||
// Update states when data changes (for edit mode)
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
setUrl(data.server_url || '')
|
||||
setName(data.name || '')
|
||||
setServerIdentifier(data.server_identifier || '')
|
||||
setMcpTimeout(data.configuration?.timeout || 30)
|
||||
setSseReadTimeout(data.configuration?.sse_read_timeout || 300)
|
||||
setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })))
|
||||
setAppIcon(getIcon(data))
|
||||
setIsDynamicRegistration(data.is_dynamic_registration)
|
||||
setClientID(data.authentication?.client_id || '')
|
||||
setCredentials(data.authentication?.client_secret || '')
|
||||
}
|
||||
else {
|
||||
// Reset for create mode
|
||||
setUrl('')
|
||||
setName('')
|
||||
setServerIdentifier('')
|
||||
setMcpTimeout(30)
|
||||
setSseReadTimeout(300)
|
||||
setHeaders([])
|
||||
setAppIcon(DEFAULT_ICON as AppIconSelection)
|
||||
setIsDynamicRegistration(true)
|
||||
setClientID('')
|
||||
setCredentials('')
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const isValidUrl = (string: string) => {
|
||||
try {
|
||||
const url = new URL(string)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isValidServerID = (str: string) => {
|
||||
return /^[a-z0-9_-]{1,24}$/.test(str)
|
||||
}
|
||||
|
||||
const handleBlur = async (url: string) => {
|
||||
if (data)
|
||||
return
|
||||
if (!isValidUrl(url))
|
||||
return
|
||||
const domain = getDomain(url)
|
||||
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
|
||||
setIsFetchingIcon(true)
|
||||
try {
|
||||
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
|
||||
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
|
||||
}
|
||||
catch (e) {
|
||||
let errorMessage = 'Failed to fetch remote icon'
|
||||
const errorData = await (e as Response).json()
|
||||
if (errorData?.code)
|
||||
errorMessage = `Upload failed: ${errorData.code}`
|
||||
console.error('Failed to fetch remote icon:', e)
|
||||
Toast.notify({ type: 'warning', message: errorMessage })
|
||||
}
|
||||
finally {
|
||||
setIsFetchingIcon(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!isValidUrl(url)) {
|
||||
if (!isValidUrl(state.url)) {
|
||||
Toast.notify({ type: 'error', message: 'invalid server url' })
|
||||
return
|
||||
}
|
||||
if (!isValidServerID(serverIdentifier.trim())) {
|
||||
if (!isValidServerID(state.serverIdentifier.trim())) {
|
||||
Toast.notify({ type: 'error', message: 'invalid server identifier' })
|
||||
return
|
||||
}
|
||||
const formattedHeaders = headers.reduce((acc, item) => {
|
||||
const formattedHeaders = state.headers.reduce((acc, item) => {
|
||||
if (item.key.trim())
|
||||
acc[item.key.trim()] = item.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
await onConfirm({
|
||||
server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
|
||||
name,
|
||||
icon_type: appIcon.type,
|
||||
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
|
||||
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
|
||||
server_identifier: serverIdentifier.trim(),
|
||||
server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(),
|
||||
name: state.name,
|
||||
icon_type: state.appIcon.type,
|
||||
icon: state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId,
|
||||
icon_background: state.appIcon.type === 'emoji' ? state.appIcon.background : undefined,
|
||||
server_identifier: state.serverIdentifier.trim(),
|
||||
headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined,
|
||||
is_dynamic_registration: isDynamicRegistration,
|
||||
is_dynamic_registration: state.isDynamicRegistration,
|
||||
authentication: {
|
||||
client_id: clientID,
|
||||
client_secret: credentials,
|
||||
client_id: state.clientID,
|
||||
client_secret: state.credentials,
|
||||
},
|
||||
configuration: {
|
||||
timeout: timeout || 30,
|
||||
sse_read_timeout: sseReadTimeout || 300,
|
||||
timeout: state.timeout || 30,
|
||||
sse_read_timeout: state.sseReadTimeout || 300,
|
||||
},
|
||||
})
|
||||
if (isCreate)
|
||||
onHide()
|
||||
}
|
||||
|
||||
const handleAuthMethodChange = useCallback((value: string) => {
|
||||
setAuthMethod(value as MCPAuthMethod)
|
||||
}, [])
|
||||
const handleIconSelect = (payload: AppIconSelection) => {
|
||||
actions.setAppIcon(payload)
|
||||
actions.setShowAppIconPicker(false)
|
||||
}
|
||||
|
||||
const handleIconClose = () => {
|
||||
actions.resetIcon()
|
||||
actions.setShowAppIconPicker(false)
|
||||
}
|
||||
|
||||
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className={cn('relative !max-w-[520px]', 'p-6')}
|
||||
>
|
||||
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}</div>
|
||||
<div className="space-y-5 py-3">
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{originalServerUrl && originalServerUrl !== url && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">
|
||||
{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 py-3">
|
||||
{/* Server URL */}
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="grow pb-1">
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2" ref={appIconRef}>
|
||||
<AppIcon
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
|
||||
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
|
||||
size="xxl"
|
||||
className="relative cursor-pointer rounded-2xl"
|
||||
coverElement={
|
||||
isHovering
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
|
||||
<RiEditLine className="size-6 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
value={serverIdentifier}
|
||||
onChange={e => setServerIdentifier(e.target.value)}
|
||||
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{originalServerID && originalServerID !== serverIdentifier && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabSlider
|
||||
className="w-full"
|
||||
itemClassName={(isActive) => {
|
||||
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
|
||||
}}
|
||||
value={authMethod}
|
||||
onChange={handleAuthMethodChange}
|
||||
options={authMethods}
|
||||
<Input
|
||||
value={state.url}
|
||||
onChange={e => actions.setUrl(e.target.value)}
|
||||
onBlur={e => actions.handleUrlBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{
|
||||
authMethod === MCPAuthMethod.authentication && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
defaultValue={isDynamicRegistration}
|
||||
onChange={setIsDynamicRegistration}
|
||||
/>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
{!isDynamicRegistration && (
|
||||
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
|
||||
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
|
||||
{`${API_PREFIX}/mcp/oauth/callback`}
|
||||
</code>
|
||||
{originalServerUrl && originalServerUrl !== state.url && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Icon */}
|
||||
<div className="flex space-x-3">
|
||||
<div className="grow pb-1">
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={state.name}
|
||||
onChange={e => actions.setName(e.target.value)}
|
||||
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2" ref={appIconRef}>
|
||||
<AppIcon
|
||||
iconType={state.appIcon.type}
|
||||
icon={state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId}
|
||||
background={state.appIcon.type === 'emoji' ? state.appIcon.background : undefined}
|
||||
imageUrl={state.appIcon.type === 'image' ? state.appIcon.url : undefined}
|
||||
innerIcon={shouldUseMcpIconForAppIcon(state.appIcon.type, state.appIcon.type === 'emoji' ? state.appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
|
||||
size="xxl"
|
||||
className="relative cursor-pointer rounded-2xl"
|
||||
coverElement={
|
||||
isHovering
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
|
||||
<RiEditLine className="size-6 text-text-primary-on-surface" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={clientID}
|
||||
onChange={e => setClientID(e.target.value)}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={credentials}
|
||||
onChange={e => setCredentials(e.target.value)}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
authMethod === MCPAuthMethod.headers && (
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
|
||||
<HeadersInput
|
||||
headersItems={headers}
|
||||
onChange={setHeaders}
|
||||
readonly={false}
|
||||
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
authMethod === MCPAuthMethod.configurations && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={e => setMcpTimeout(Number(e.target.value))}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={sseReadTimeout}
|
||||
onChange={e => setSseReadTimeout(Number(e.target.value))}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
: null
|
||||
}
|
||||
onClick={() => actions.setShowAppIconPicker(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse pt-5">
|
||||
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}</Button>
|
||||
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
|
||||
|
||||
{/* Server Identifier */}
|
||||
<div>
|
||||
<div className="flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
value={state.serverIdentifier}
|
||||
onChange={e => actions.setServerIdentifier(e.target.value)}
|
||||
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{originalServerID && originalServerID !== state.serverIdentifier && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
{showAppIconPicker && (
|
||||
|
||||
{/* Auth Method Tabs */}
|
||||
<TabSlider
|
||||
className="w-full"
|
||||
itemClassName={isActive => `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`}
|
||||
value={state.authMethod}
|
||||
onChange={actions.setAuthMethod}
|
||||
options={authMethods}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
{state.authMethod === MCPAuthMethod.authentication && (
|
||||
<AuthenticationSection
|
||||
isDynamicRegistration={state.isDynamicRegistration}
|
||||
onDynamicRegistrationChange={actions.setIsDynamicRegistration}
|
||||
clientID={state.clientID}
|
||||
onClientIDChange={actions.setClientID}
|
||||
credentials={state.credentials}
|
||||
onCredentialsChange={actions.setCredentials}
|
||||
/>
|
||||
)}
|
||||
{state.authMethod === MCPAuthMethod.headers && (
|
||||
<HeadersSection
|
||||
headers={state.headers}
|
||||
onHeadersChange={actions.setHeaders}
|
||||
isCreate={isCreate}
|
||||
/>
|
||||
)}
|
||||
{state.authMethod === MCPAuthMethod.configurations && (
|
||||
<ConfigurationsSection
|
||||
timeout={state.timeout}
|
||||
onTimeoutChange={actions.setTimeout}
|
||||
sseReadTimeout={state.sseReadTimeout}
|
||||
onSseReadTimeoutChange={actions.setSseReadTimeout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-row-reverse pt-5">
|
||||
<Button disabled={isSubmitDisabled} className="ml-2" variant="primary" onClick={submit}>
|
||||
{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}
|
||||
</Button>
|
||||
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
|
||||
</div>
|
||||
|
||||
{state.showAppIconPicker && (
|
||||
<AppIconPicker
|
||||
onSelect={(payload) => {
|
||||
setAppIcon(payload)
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppIcon(getIcon(data))
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
onSelect={handleIconSelect}
|
||||
onClose={handleIconClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Modal component for creating and editing MCP server configurations.
|
||||
*
|
||||
* Uses a keyed inner component to ensure form state resets when switching
|
||||
* between create mode and edit mode with different data.
|
||||
*/
|
||||
const MCPModal: FC<DuplicateAppModalProps> = ({
|
||||
data,
|
||||
show,
|
||||
onConfirm,
|
||||
onHide,
|
||||
}) => {
|
||||
// Use data ID as key to reset form state when switching between items
|
||||
const formKey = data?.id ?? 'create'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className={cn('relative !max-w-[520px]', 'p-6')}
|
||||
>
|
||||
<MCPModalContent
|
||||
key={formKey}
|
||||
data={data}
|
||||
onConfirm={onConfirm}
|
||||
onHide={onHide}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
524
web/app/components/tools/mcp/provider-card.spec.tsx
Normal file
524
web/app/components/tools/mcp/provider-card.spec.tsx
Normal file
@ -0,0 +1,524 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MCPCard from './provider-card'
|
||||
|
||||
// Mutable mock functions
|
||||
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
|
||||
// Mock the services
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useUpdateMCP: () => ({
|
||||
mutateAsync: mockUpdateMCP,
|
||||
}),
|
||||
useDeleteMCP: () => ({
|
||||
mutateAsync: mockDeleteMCP,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the MCPModal
|
||||
type MCPModalForm = {
|
||||
name: string
|
||||
server_url: string
|
||||
}
|
||||
|
||||
type MCPModalProps = {
|
||||
show: boolean
|
||||
onConfirm: (form: MCPModalForm) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
vi.mock('./modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="mcp-modal">
|
||||
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="modal-close-btn" onClick={onHide}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the Confirm dialog
|
||||
type ConfirmDialogProps = {
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, isLoading }: ConfirmDialogProps) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-dialog">
|
||||
<button data-testid="confirm-delete-btn" onClick={onConfirm} disabled={isLoading}>
|
||||
{isLoading ? 'Deleting...' : 'Confirm Delete'}
|
||||
</button>
|
||||
<button data-testid="cancel-delete-btn" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the OperationDropdown
|
||||
type OperationDropdownProps = {
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
vi.mock('./detail/operation-dropdown', () => ({
|
||||
default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => (
|
||||
<div data-testid="operation-dropdown">
|
||||
<button
|
||||
data-testid="edit-btn"
|
||||
onClick={() => {
|
||||
onOpenChange(true)
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
data-testid="remove-btn"
|
||||
onClick={() => {
|
||||
onOpenChange(true)
|
||||
onRemove()
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock the app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the format time hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (_timestamp: number) => '2 hours ago',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the plugins service
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { pages: [] },
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock common service
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
|
||||
}))
|
||||
|
||||
describe('MCPCard', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockData = (overrides = {}): ToolWithProvider => ({
|
||||
id: 'mcp-1',
|
||||
name: 'Test MCP Server',
|
||||
server_identifier: 'test-server',
|
||||
icon: { content: '🔧', background: '#FF0000' },
|
||||
tools: [
|
||||
{ name: 'tool1', description: 'Tool 1' },
|
||||
{ name: 'tool2', description: 'Tool 2' },
|
||||
],
|
||||
is_team_authorization: true,
|
||||
updated_at: Date.now() / 1000,
|
||||
...overrides,
|
||||
} as unknown as ToolWithProvider)
|
||||
|
||||
const defaultProps = {
|
||||
data: createMockData(),
|
||||
handleSelect: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
onDeleted: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateMCP.mockClear()
|
||||
mockDeleteMCP.mockClear()
|
||||
mockUpdateMCP.mockResolvedValue({ result: 'success' })
|
||||
mockDeleteMCP.mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display MCP name', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display server identifier', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('test-server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display tools count', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// The tools count uses i18n with count parameter
|
||||
expect(screen.getByText(/tools.mcp.toolsCount/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display update time', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText(/tools.mcp.updateTime/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('No Tools State', () => {
|
||||
it('should show no tools message when tools array is empty', () => {
|
||||
const dataWithNoTools = createMockData({ tools: [] })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={dataWithNoTools} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.noTools')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not configured badge when not authorized', () => {
|
||||
const dataNotAuthorized = createMockData({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={dataNotAuthorized} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not configured badge when no tools', () => {
|
||||
const dataWithNoTools = createMockData({ tools: [], is_team_authorization: true })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={dataWithNoTools} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selected State', () => {
|
||||
it('should apply selected styles when current provider matches', () => {
|
||||
render(
|
||||
<MCPCard {...defaultProps} currentProvider={defaultProps.data} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
|
||||
expect(card).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not apply selected styles when different provider', () => {
|
||||
const differentProvider = createMockData({ id: 'different-id' })
|
||||
render(
|
||||
<MCPCard {...defaultProps} currentProvider={differentProvider} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
|
||||
expect(card).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleSelect when card is clicked', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<MCPCard {...defaultProps} handleSelect={handleSelect} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const card = screen.getByText('Test MCP Server').closest('[class*="cursor-pointer"]')
|
||||
if (card) {
|
||||
fireEvent.click(card)
|
||||
expect(handleSelect).toHaveBeenCalledWith('mcp-1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Card Icon', () => {
|
||||
it('should render card icon', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Icon component is rendered
|
||||
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Indicator', () => {
|
||||
it('should show green indicator when authorized and has tools', () => {
|
||||
const data = createMockData({ is_team_authorization: true, tools: [{ name: 'tool1' }] })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={data} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
// Should have green indicator (not showing red badge)
|
||||
expect(screen.queryByText('tools.mcp.noConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show red indicator when not configured', () => {
|
||||
const data = createMockData({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={data} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle long MCP name', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
const data = createMockData({ name: longName })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={data} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText(longName)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
const data = createMockData({ name: 'Test <Script> & "Quotes"' })
|
||||
render(
|
||||
<MCPCard {...defaultProps} data={data} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Test <Script> & "Quotes"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined currentProvider', () => {
|
||||
render(
|
||||
<MCPCard {...defaultProps} currentProvider={undefined} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Operation Dropdown', () => {
|
||||
it('should render operation dropdown for workspace managers', () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop propagation when clicking on dropdown container', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { wrapper: createWrapper() })
|
||||
|
||||
// Click on the dropdown area (which should stop propagation)
|
||||
const dropdown = screen.getByTestId('operation-dropdown')
|
||||
const dropdownContainer = dropdown.closest('[class*="absolute"]')
|
||||
if (dropdownContainer) {
|
||||
fireEvent.click(dropdownContainer)
|
||||
// handleSelect should NOT be called because stopPropagation
|
||||
expect(handleSelect).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Modal', () => {
|
||||
it('should open update modal when edit button is clicked', async () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Click the edit button
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
// Modal should be shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close update modal when close button is clicked', async () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close the modal
|
||||
const closeBtn = screen.getByTestId('modal-close-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateMCP and onUpdate when form is confirmed', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm the form
|
||||
const confirmBtn = screen.getByTestId('modal-confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMCP).toHaveBeenCalledWith({
|
||||
name: 'Updated MCP',
|
||||
server_url: 'https://updated.com',
|
||||
provider_id: 'mcp-1',
|
||||
})
|
||||
expect(onUpdate).toHaveBeenCalledWith('mcp-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onUpdate when updateMCP fails', async () => {
|
||||
mockUpdateMCP.mockResolvedValue({ result: 'error' })
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm the form
|
||||
const confirmBtn = screen.getByTestId('modal-confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMCP).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// onUpdate should not be called because result is not 'success'
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Confirm', () => {
|
||||
it('should open delete confirm when remove button is clicked', async () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Click the remove button
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
// Confirm dialog should be shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close delete confirm when cancel button is clicked', async () => {
|
||||
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the confirm dialog
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cancel
|
||||
const cancelBtn = screen.getByTestId('cancel-delete-btn')
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deleteMCP and onDeleted when delete is confirmed', async () => {
|
||||
const onDeleted = vi.fn()
|
||||
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the confirm dialog
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByTestId('confirm-delete-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
|
||||
expect(onDeleted).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onDeleted when deleteMCP fails', async () => {
|
||||
mockDeleteMCP.mockResolvedValue({ result: 'error' })
|
||||
const onDeleted = vi.fn()
|
||||
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the confirm dialog
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByTestId('confirm-delete-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMCP).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// onDeleted should not be called because result is not 'success'
|
||||
expect(onDeleted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AuthenticationSection from './authentication-section'
|
||||
|
||||
describe('AuthenticationSection', () => {
|
||||
const defaultProps = {
|
||||
isDynamicRegistration: true,
|
||||
onDynamicRegistrationChange: vi.fn(),
|
||||
clientID: '',
|
||||
onClientIDChange: vi.fn(),
|
||||
credentials: '',
|
||||
onCredentialsChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AuthenticationSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render switch for dynamic registration', () => {
|
||||
render(<AuthenticationSection {...defaultProps} />)
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render client ID input', () => {
|
||||
render(<AuthenticationSection {...defaultProps} clientID="test-client-id" />)
|
||||
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credentials input', () => {
|
||||
render(<AuthenticationSection {...defaultProps} credentials="test-secret" />)
|
||||
expect(screen.getByDisplayValue('test-secret')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render labels for all fields', () => {
|
||||
render(<AuthenticationSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.clientID')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.clientSecret')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic Registration Toggle', () => {
|
||||
it('should not show warning when isDynamicRegistration is true', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.redirectUrlWarning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when isDynamicRegistration is false', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
|
||||
expect(screen.getByText('tools.mcp.modal.redirectUrlWarning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show OAuth callback URL in warning', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
|
||||
expect(screen.getByText(/\/mcp\/oauth\/callback/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable inputs when isDynamicRegistration is true', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should enable inputs when isDynamicRegistration is false', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
inputs.forEach((input) => {
|
||||
expect(input).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onDynamicRegistrationChange when switch is toggled', () => {
|
||||
const onDynamicRegistrationChange = vi.fn()
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
onDynamicRegistrationChange={onDynamicRegistrationChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
fireEvent.click(switchElement)
|
||||
|
||||
expect(onDynamicRegistrationChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClientIDChange when client ID input changes', () => {
|
||||
const onClientIDChange = vi.fn()
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
isDynamicRegistration={false}
|
||||
onClientIDChange={onClientIDChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const clientIDInput = inputs[0]
|
||||
fireEvent.change(clientIDInput, { target: { value: 'new-client-id' } })
|
||||
|
||||
expect(onClientIDChange).toHaveBeenCalledWith('new-client-id')
|
||||
})
|
||||
|
||||
it('should call onCredentialsChange when credentials input changes', () => {
|
||||
const onCredentialsChange = vi.fn()
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
isDynamicRegistration={false}
|
||||
onCredentialsChange={onCredentialsChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const credentialsInput = inputs[1]
|
||||
fireEvent.change(credentialsInput, { target: { value: 'new-secret' } })
|
||||
|
||||
expect(onCredentialsChange).toHaveBeenCalledWith('new-secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display provided clientID value', () => {
|
||||
render(<AuthenticationSection {...defaultProps} clientID="my-client-123" />)
|
||||
expect(screen.getByDisplayValue('my-client-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display provided credentials value', () => {
|
||||
render(<AuthenticationSection {...defaultProps} credentials="secret-456" />)
|
||||
expect(screen.getByDisplayValue('secret-456')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
render(<AuthenticationSection {...defaultProps} clientID="" credentials="" />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(2)
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in values', () => {
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
clientID="client@123!#$"
|
||||
credentials="secret&*()_+"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByDisplayValue('client@123!#$')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('secret&*()_+')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type AuthenticationSectionProps = {
|
||||
isDynamicRegistration: boolean
|
||||
onDynamicRegistrationChange: (value: boolean) => void
|
||||
clientID: string
|
||||
onClientIDChange: (value: string) => void
|
||||
credentials: string
|
||||
onCredentialsChange: (value: string) => void
|
||||
}
|
||||
|
||||
const AuthenticationSection: FC<AuthenticationSectionProps> = ({
|
||||
isDynamicRegistration,
|
||||
onDynamicRegistrationChange,
|
||||
clientID,
|
||||
onClientIDChange,
|
||||
credentials,
|
||||
onCredentialsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
defaultValue={isDynamicRegistration}
|
||||
onChange={onDynamicRegistrationChange}
|
||||
/>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
{!isDynamicRegistration && (
|
||||
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
|
||||
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
|
||||
{`${API_PREFIX}/mcp/oauth/callback`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={clientID}
|
||||
onChange={e => onClientIDChange(e.target.value)}
|
||||
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={credentials}
|
||||
onChange={e => onCredentialsChange(e.target.value)}
|
||||
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthenticationSection
|
||||
@ -0,0 +1,100 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ConfigurationsSection from './configurations-section'
|
||||
|
||||
describe('ConfigurationsSection', () => {
|
||||
const defaultProps = {
|
||||
timeout: 30,
|
||||
onTimeoutChange: vi.fn(),
|
||||
sseReadTimeout: 300,
|
||||
onSseReadTimeoutChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('300')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render timeout input with correct value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
const timeoutInput = screen.getByDisplayValue('30')
|
||||
expect(timeoutInput).toHaveAttribute('type', 'number')
|
||||
})
|
||||
|
||||
it('should render SSE read timeout input with correct value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
const sseInput = screen.getByDisplayValue('300')
|
||||
expect(sseInput).toHaveAttribute('type', 'number')
|
||||
})
|
||||
|
||||
it('should render labels for both inputs', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
// i18n keys are rendered as-is in test environment
|
||||
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display custom timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} timeout={60} />)
|
||||
expect(screen.getByDisplayValue('60')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display custom SSE read timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={600} />)
|
||||
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onTimeoutChange when timeout input changes', () => {
|
||||
const onTimeoutChange = vi.fn()
|
||||
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('30')
|
||||
fireEvent.change(timeoutInput, { target: { value: '45' } })
|
||||
|
||||
expect(onTimeoutChange).toHaveBeenCalledWith(45)
|
||||
})
|
||||
|
||||
it('should call onSseReadTimeoutChange when SSE timeout input changes', () => {
|
||||
const onSseReadTimeoutChange = vi.fn()
|
||||
render(<ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />)
|
||||
|
||||
const sseInput = screen.getByDisplayValue('300')
|
||||
fireEvent.change(sseInput, { target: { value: '500' } })
|
||||
|
||||
expect(onSseReadTimeoutChange).toHaveBeenCalledWith(500)
|
||||
})
|
||||
|
||||
it('should handle numeric conversion correctly', () => {
|
||||
const onTimeoutChange = vi.fn()
|
||||
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('30')
|
||||
fireEvent.change(timeoutInput, { target: { value: '0' } })
|
||||
|
||||
expect(onTimeoutChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} timeout={0} />)
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero SSE read timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={0} />)
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large timeout values', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} timeout={9999} sseReadTimeout={9999} />)
|
||||
expect(screen.getAllByDisplayValue('9999')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
type ConfigurationsSectionProps = {
|
||||
timeout: number
|
||||
onTimeoutChange: (timeout: number) => void
|
||||
sseReadTimeout: number
|
||||
onSseReadTimeoutChange: (timeout: number) => void
|
||||
}
|
||||
|
||||
const ConfigurationsSection: FC<ConfigurationsSectionProps> = ({
|
||||
timeout,
|
||||
onTimeoutChange,
|
||||
sseReadTimeout,
|
||||
onSseReadTimeoutChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={e => onTimeoutChange(Number(e.target.value))}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={sseReadTimeout}
|
||||
onChange={e => onSseReadTimeoutChange(Number(e.target.value))}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfigurationsSection
|
||||
192
web/app/components/tools/mcp/sections/headers-section.spec.tsx
Normal file
192
web/app/components/tools/mcp/sections/headers-section.spec.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HeadersSection from './headers-section'
|
||||
|
||||
describe('HeadersSection', () => {
|
||||
const defaultProps = {
|
||||
headers: [],
|
||||
onHeadersChange: vi.fn(),
|
||||
isCreate: true,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<HeadersSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render headers label', () => {
|
||||
render(<HeadersSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render headers tip', () => {
|
||||
render(<HeadersSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty state when no headers', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={[]} />)
|
||||
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add header button when empty', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={[]} />)
|
||||
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Headers', () => {
|
||||
const headersWithItems = [
|
||||
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
|
||||
{ id: '2', key: 'Content-Type', value: 'application/json' },
|
||||
]
|
||||
|
||||
it('should render header items', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
|
||||
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render table headers', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show masked tip when not isCreate and has headers with content', () => {
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
isCreate={false}
|
||||
headers={headersWithItems}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show masked tip when isCreate is true', () => {
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
isCreate={true}
|
||||
headers={headersWithItems}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHeadersChange when adding a header', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
render(<HeadersSection {...defaultProps} onHeadersChange={onHeadersChange} />)
|
||||
|
||||
const addButton = screen.getByText('tools.mcp.modal.addHeader')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalled()
|
||||
const calledWithHeaders = onHeadersChange.mock.calls[0][0]
|
||||
expect(calledWithHeaders).toHaveLength(1)
|
||||
expect(calledWithHeaders[0]).toHaveProperty('id')
|
||||
expect(calledWithHeaders[0]).toHaveProperty('key', '')
|
||||
expect(calledWithHeaders[0]).toHaveProperty('value', '')
|
||||
})
|
||||
|
||||
it('should call onHeadersChange when editing header key', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
const headers = [{ id: '1', key: '', value: '' }]
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
headers={headers}
|
||||
onHeadersChange={onHeadersChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const keyInput = inputs[0]
|
||||
fireEvent.change(keyInput, { target: { value: 'X-Custom-Header' } })
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHeadersChange when editing header value', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
const headers = [{ id: '1', key: 'X-Custom-Header', value: '' }]
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
headers={headers}
|
||||
onHeadersChange={onHeadersChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const valueInput = inputs[1]
|
||||
fireEvent.change(valueInput, { target: { value: 'custom-value' } })
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHeadersChange when removing a header', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
const headers = [{ id: '1', key: 'X-Header', value: 'value' }]
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
headers={headers}
|
||||
onHeadersChange={onHeadersChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the delete button
|
||||
const deleteButton = screen.getByRole('button', { name: '' })
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass isCreate=true correctly (no masking)', () => {
|
||||
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={true} headers={headers} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass isCreate=false correctly (with masking)', () => {
|
||||
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle headers with empty keys (no masking even when not isCreate)', () => {
|
||||
const headers = [{ id: '1', key: '', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
// Empty key headers don't trigger masking
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle headers with whitespace-only keys', () => {
|
||||
const headers = [{ id: '1', key: ' ', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
// Whitespace-only key doesn't count as having content
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple headers where some have empty keys', () => {
|
||||
const headers = [
|
||||
{ id: '1', key: '', value: 'Value1' },
|
||||
{ id: '2', key: 'ValidKey', value: 'Value2' },
|
||||
]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
// At least one header has a non-empty key, so masking should apply
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
36
web/app/components/tools/mcp/sections/headers-section.tsx
Normal file
36
web/app/components/tools/mcp/sections/headers-section.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { HeaderItem } from '../headers-input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import HeadersInput from '../headers-input'
|
||||
|
||||
type HeadersSectionProps = {
|
||||
headers: HeaderItem[]
|
||||
onHeadersChange: (headers: HeaderItem[]) => void
|
||||
isCreate: boolean
|
||||
}
|
||||
|
||||
const HeadersSection: FC<HeadersSectionProps> = ({
|
||||
headers,
|
||||
onHeadersChange,
|
||||
isCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
|
||||
<HeadersInput
|
||||
headersItems={headers}
|
||||
onChange={onHeadersChange}
|
||||
readonly={false}
|
||||
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeadersSection
|
||||
@ -1,705 +0,0 @@
|
||||
/**
|
||||
* Test Suite for useNodesSyncDraft Hook
|
||||
*
|
||||
* PURPOSE:
|
||||
* This hook handles syncing workflow draft to the server. The key fix being tested
|
||||
* is the error handling behavior when `draft_workflow_not_sync` error occurs.
|
||||
*
|
||||
* MULTI-TAB PROBLEM SCENARIO:
|
||||
* 1. User opens the same workflow in Tab A and Tab B (both have hash: v1)
|
||||
* 2. Tab A saves successfully, server returns new hash: v2
|
||||
* 3. Tab B tries to save with old hash: v1, server returns 400 error with code
|
||||
* 'draft_workflow_not_sync'
|
||||
* 4. BEFORE FIX: handleRefreshWorkflowDraft() was called without args, which fetched
|
||||
* draft AND overwrote canvas - user lost unsaved changes in Tab B
|
||||
* 5. AFTER FIX: handleRefreshWorkflowDraft(true) is called, which fetches draft but
|
||||
* only updates hash (notUpdateCanvas=true), preserving user's canvas changes
|
||||
*
|
||||
* TESTING STRATEGY:
|
||||
* We don't simulate actual tab switching UI behavior. Instead, we mock the API to
|
||||
* return `draft_workflow_not_sync` error and verify:
|
||||
* - The hook calls handleRefreshWorkflowDraft(true) - not handleRefreshWorkflowDraft()
|
||||
* - This ensures canvas data is preserved while hash is updated for retry
|
||||
*
|
||||
* This is behavior-driven testing - we verify "what the code does when receiving
|
||||
* specific API errors" rather than simulating complete user interaction flows.
|
||||
* True multi-tab integration testing would require E2E frameworks like Playwright.
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
// Mock reactflow store
|
||||
const mockGetNodes = vi.fn()
|
||||
|
||||
type MockEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mockStoreState: {
|
||||
getNodes: ReturnType<typeof vi.fn>
|
||||
edges: MockEdge[]
|
||||
transform: number[]
|
||||
} = {
|
||||
getNodes: mockGetNodes,
|
||||
edges: [],
|
||||
transform: [0, 0, 1],
|
||||
}
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => mockStoreState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock features store
|
||||
const mockFeaturesState = {
|
||||
features: {
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
suggested: {},
|
||||
text2speech: {},
|
||||
speech2text: {},
|
||||
citation: {},
|
||||
moderation: {},
|
||||
file: {},
|
||||
},
|
||||
}
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => mockFeaturesState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow service
|
||||
const mockSyncWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
|
||||
}))
|
||||
|
||||
// Mock useNodesReadOnly
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useSerialAsyncCallback - pass through the callback
|
||||
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
|
||||
useSerialAsyncCallback: (callback: (...args: unknown[]) => unknown) => callback,
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
|
||||
const createMockWorkflowStoreState = (overrides = {}) => ({
|
||||
appId: 'test-app-id',
|
||||
conversationVariables: [],
|
||||
environmentVariables: [],
|
||||
syncWorkflowDraftHash: 'current-hash-123',
|
||||
isWorkflowDataLoaded: true,
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setDraftUpdatedAt: mockSetDraftUpdatedAt,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useWorkflowRefreshDraft (THE KEY DEPENDENCY FOR THIS TEST)
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
vi.mock('.', () => ({
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API_PREFIX
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
}))
|
||||
|
||||
// Create a mock error response that mimics the actual API error
|
||||
const createMockErrorResponse = (code: string) => {
|
||||
const errorBody = { code, message: 'Draft not in sync' }
|
||||
let bodyUsed = false
|
||||
|
||||
return {
|
||||
json: vi.fn().mockImplementation(() => {
|
||||
bodyUsed = true
|
||||
return Promise.resolve(errorBody)
|
||||
}),
|
||||
get bodyUsed() {
|
||||
return bodyUsed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodesSyncDraft', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', type: 'start', data: { type: 'start' } },
|
||||
{ id: 'node-2', type: 'llm', data: { type: 'llm' } },
|
||||
])
|
||||
mockStoreState.edges = [
|
||||
{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} },
|
||||
]
|
||||
mockWorkflowStoreGetState.mockReturnValue(createMockWorkflowStoreState())
|
||||
mockSyncWorkflowDraft.mockResolvedValue({
|
||||
hash: 'new-hash-456',
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('doSyncWorkflowDraft function', () => {
|
||||
it('should return doSyncWorkflowDraft function', () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
expect(result.current.doSyncWorkflowDraft).toBeDefined()
|
||||
expect(typeof result.current.doSyncWorkflowDraft).toBe('function')
|
||||
})
|
||||
|
||||
it('should return syncWorkflowDraftWhenPageClose function', () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
expect(result.current.syncWorkflowDraftWhenPageClose).toBeDefined()
|
||||
expect(typeof result.current.syncWorkflowDraftWhenPageClose).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('successful sync', () => {
|
||||
it('should call syncWorkflowDraft service on successful sync', async () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: '/apps/test-app-id/workflows/draft',
|
||||
params: expect.objectContaining({
|
||||
hash: 'current-hash-123',
|
||||
graph: expect.objectContaining({
|
||||
nodes: expect.any(Array),
|
||||
edges: expect.any(Array),
|
||||
viewport: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should update syncWorkflowDraftHash on success', async () => {
|
||||
mockSyncWorkflowDraft.mockResolvedValue({
|
||||
hash: 'new-hash-789',
|
||||
updated_at: 1234567890,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-789')
|
||||
})
|
||||
|
||||
it('should update draftUpdatedAt on success', async () => {
|
||||
const updatedAt = 1234567890
|
||||
mockSyncWorkflowDraft.mockResolvedValue({
|
||||
hash: 'new-hash',
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(updatedAt)
|
||||
})
|
||||
|
||||
it('should call onSuccess callback on success', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSuccess })
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSettled callback after success', async () => {
|
||||
const onSettled = vi.fn()
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
|
||||
expect(onSettled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync error handling - draft_workflow_not_sync (THE KEY FIX)', () => {
|
||||
/**
|
||||
* This is THE KEY TEST for the bug fix.
|
||||
*
|
||||
* SCENARIO: Multi-tab editing
|
||||
* 1. User opens workflow in Tab A and Tab B
|
||||
* 2. Tab A saves draft successfully, gets new hash
|
||||
* 3. Tab B tries to save with old hash
|
||||
* 4. Server returns 400 with code 'draft_workflow_not_sync'
|
||||
*
|
||||
* BEFORE FIX:
|
||||
* - handleRefreshWorkflowDraft() was called without arguments
|
||||
* - This would fetch draft AND overwrite the canvas
|
||||
* - User loses their unsaved changes in Tab B
|
||||
*
|
||||
* AFTER FIX:
|
||||
* - handleRefreshWorkflowDraft(true) is called
|
||||
* - This fetches draft but DOES NOT overwrite canvas
|
||||
* - Only hash is updated for the next sync attempt
|
||||
* - User's unsaved changes are preserved
|
||||
*/
|
||||
it('should call handleRefreshWorkflowDraft with notUpdateCanvas=true when draft_workflow_not_sync error occurs', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// THE KEY ASSERTION: handleRefreshWorkflowDraft must be called with true
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT call handleRefreshWorkflowDraft when notRefreshWhenSyncError is true', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
// First parameter is notRefreshWhenSyncError
|
||||
await result.current.doSyncWorkflowDraft(true)
|
||||
})
|
||||
|
||||
// Wait a bit for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onError callback when draft_workflow_not_sync error occurs', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
const onError = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onError })
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSettled callback after error', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
|
||||
expect(onSettled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('other error handling', () => {
|
||||
it('should NOT call handleRefreshWorkflowDraft for non-draft_workflow_not_sync errors', async () => {
|
||||
const mockError = createMockErrorResponse('some_other_error')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Wait a bit for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle error without json method', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
const onError = vi.fn()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onError })
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle error with bodyUsed already true', async () => {
|
||||
const mockError = {
|
||||
json: vi.fn(),
|
||||
bodyUsed: true,
|
||||
}
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Should not call json() when bodyUsed is true
|
||||
expect(mockError.json).not.toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only mode', () => {
|
||||
it('should not sync when nodes are read-only', async () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not sync on page close when nodes are read-only', () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
|
||||
// Mock sendBeacon
|
||||
const mockSendBeacon = vi.fn()
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: mockSendBeacon,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow data not loaded', () => {
|
||||
it('should not sync when workflow data is not loaded', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockWorkflowStoreState({ isWorkflowDataLoaded: false }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no appId', () => {
|
||||
it('should not sync when appId is not set', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockWorkflowStoreState({ appId: null }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node filtering', () => {
|
||||
it('should filter out temp nodes', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', type: 'start', data: { type: 'start' } },
|
||||
{ id: 'node-temp', type: 'custom', data: { type: 'custom', _isTempNode: true } },
|
||||
{ id: 'node-2', type: 'llm', data: { type: 'llm' } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
graph: expect.objectContaining({
|
||||
nodes: expect.not.arrayContaining([
|
||||
expect.objectContaining({ id: 'node-temp' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove internal underscore properties from nodes', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'start',
|
||||
data: {
|
||||
type: 'start',
|
||||
_internalProp: 'should be removed',
|
||||
_anotherInternal: true,
|
||||
publicProp: 'should remain',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
|
||||
const sentNode = callArgs.params.graph.nodes[0]
|
||||
|
||||
expect(sentNode.data).not.toHaveProperty('_internalProp')
|
||||
expect(sentNode.data).not.toHaveProperty('_anotherInternal')
|
||||
expect(sentNode.data).toHaveProperty('publicProp', 'should remain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge filtering', () => {
|
||||
it('should filter out temp edges', async () => {
|
||||
mockStoreState.edges = [
|
||||
{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} },
|
||||
{ id: 'edge-temp', source: 'node-1', target: 'node-3', data: { _isTemp: true } },
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
|
||||
const sentEdges = callArgs.params.graph.edges
|
||||
|
||||
expect(sentEdges).toHaveLength(1)
|
||||
expect(sentEdges[0].id).toBe('edge-1')
|
||||
})
|
||||
|
||||
it('should remove internal underscore properties from edges', async () => {
|
||||
mockStoreState.edges = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
data: {
|
||||
_internalEdgeProp: 'should be removed',
|
||||
publicEdgeProp: 'should remain',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
|
||||
const sentEdge = callArgs.params.graph.edges[0]
|
||||
|
||||
expect(sentEdge.data).not.toHaveProperty('_internalEdgeProp')
|
||||
expect(sentEdge.data).toHaveProperty('publicEdgeProp', 'should remain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport handling', () => {
|
||||
it('should send current viewport from transform', async () => {
|
||||
mockStoreState.transform = [100, 200, 1.5]
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
graph: expect.objectContaining({
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-tab concurrent editing scenario (END-TO-END TEST)', () => {
|
||||
/**
|
||||
* Simulates the complete multi-tab scenario to verify the fix works correctly.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Tab A and Tab B both have the workflow open with hash 'hash-v1'
|
||||
* 2. Tab A saves successfully, server returns 'hash-v2'
|
||||
* 3. Tab B tries to save with 'hash-v1', gets 'draft_workflow_not_sync' error
|
||||
* 4. Tab B should only update hash to 'hash-v2', not overwrite canvas
|
||||
* 5. Tab B can now retry save with correct hash
|
||||
*/
|
||||
it('should preserve canvas data during hash conflict resolution', async () => {
|
||||
// Initial state: both tabs have hash-v1
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockWorkflowStoreState({ syncWorkflowDraftHash: 'hash-v1' }),
|
||||
)
|
||||
|
||||
// Tab B tries to save with old hash, server returns error
|
||||
const syncError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(syncError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
// Tab B attempts to sync
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Verify the sync was attempted with old hash
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
hash: 'hash-v1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify handleRefreshWorkflowDraft was called with true (not overwrite canvas)
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
// The key assertion: only one argument (true) was passed
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleRefreshWorkflowDraft.mock.calls[0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive sync failures gracefully', async () => {
|
||||
// Create fresh error for each call to avoid bodyUsed issue
|
||||
mockSyncWorkflowDraft
|
||||
.mockRejectedValueOnce(createMockErrorResponse('draft_workflow_not_sync'))
|
||||
.mockRejectedValueOnce(createMockErrorResponse('draft_workflow_not_sync'))
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
// First sync attempt
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Wait for first refresh call
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Second sync attempt
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Both should call handleRefreshWorkflowDraft with true
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
mockHandleRefreshWorkflowDraft.mock.calls.forEach((call) => {
|
||||
expect(call).toEqual([true])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('callbacks behavior', () => {
|
||||
it('should not call onSuccess when sync fails', async () => {
|
||||
const syncError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(syncError)
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSuccess, onError })
|
||||
})
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should always call onSettled regardless of success or failure', async () => {
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
// Test success case
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Reset
|
||||
onSettled.mockClear()
|
||||
|
||||
// Test failure case
|
||||
const syncError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(syncError)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -115,7 +115,7 @@ export const useNodesSyncDraft = () => {
|
||||
if (error && error.json && !error.bodyUsed) {
|
||||
error.json().then((err: any) => {
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
|
||||
handleRefreshWorkflowDraft(true)
|
||||
handleRefreshWorkflowDraft()
|
||||
})
|
||||
}
|
||||
callback?.onError?.()
|
||||
|
||||
@ -1,556 +0,0 @@
|
||||
/**
|
||||
* Test Suite for useWorkflowRefreshDraft Hook
|
||||
*
|
||||
* PURPOSE:
|
||||
* This hook is responsible for refreshing workflow draft data from the server.
|
||||
* The key fix being tested is the `notUpdateCanvas` parameter behavior.
|
||||
*
|
||||
* MULTI-TAB PROBLEM SCENARIO:
|
||||
* 1. User opens the same workflow in Tab A and Tab B (both have hash: v1)
|
||||
* 2. Tab A saves successfully, server returns new hash: v2
|
||||
* 3. Tab B tries to save with old hash: v1, server returns 400 error (draft_workflow_not_sync)
|
||||
* 4. BEFORE FIX: handleRefreshWorkflowDraft() was called without args, which fetched
|
||||
* draft AND overwrote canvas - user lost unsaved changes in Tab B
|
||||
* 5. AFTER FIX: handleRefreshWorkflowDraft(true) is called, which fetches draft but
|
||||
* only updates hash, preserving user's canvas changes
|
||||
*
|
||||
* TESTING STRATEGY:
|
||||
* We don't simulate actual tab switching UI behavior. Instead, we test the hook's
|
||||
* response to specific inputs:
|
||||
* - When notUpdateCanvas=true: should NOT call handleUpdateWorkflowCanvas
|
||||
* - When notUpdateCanvas=false/undefined: should call handleUpdateWorkflowCanvas
|
||||
*
|
||||
* This is behavior-driven testing - we verify "what the code does when given specific
|
||||
* inputs" rather than simulating complete user interaction flows.
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useWorkflowRefreshDraft } from './use-workflow-refresh-draft'
|
||||
|
||||
// Mock the workflow service
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||
}))
|
||||
|
||||
// Mock the workflow update hook
|
||||
const mockHandleUpdateWorkflowCanvas = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowUpdate: () => ({
|
||||
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock store state
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetIsSyncingWorkflowDraft = vi.fn()
|
||||
const mockSetEnvironmentVariables = vi.fn()
|
||||
const mockSetEnvSecrets = vi.fn()
|
||||
const mockSetConversationVariables = vi.fn()
|
||||
const mockSetIsWorkflowDataLoaded = vi.fn()
|
||||
const mockCancelDebouncedSync = vi.fn()
|
||||
|
||||
const createMockStoreState = (overrides = {}) => ({
|
||||
appId: 'test-app-id',
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
|
||||
setEnvironmentVariables: mockSetEnvironmentVariables,
|
||||
setEnvSecrets: mockSetEnvSecrets,
|
||||
setConversationVariables: mockSetConversationVariables,
|
||||
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
|
||||
isWorkflowDataLoaded: true,
|
||||
debouncedSyncWorkflowDraft: {
|
||||
cancel: mockCancelDebouncedSync,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Default mock response from fetchWorkflowDraft
|
||||
const createMockDraftResponse = (overrides = {}) => ({
|
||||
hash: 'new-hash-12345',
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1', type: 'start', data: {} }],
|
||||
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
},
|
||||
environment_variables: [
|
||||
{ id: 'env-1', name: 'API_KEY', value: 'secret-key', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'BASE_URL', value: 'https://api.example.com', value_type: 'string' },
|
||||
],
|
||||
conversation_variables: [
|
||||
{ id: 'conv-1', name: 'user_input', value: 'test' },
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useWorkflowRefreshDraft', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStoreGetState.mockReturnValue(createMockStoreState())
|
||||
mockFetchWorkflowDraft.mockResolvedValue(createMockDraftResponse())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('handleRefreshWorkflowDraft function', () => {
|
||||
it('should return handleRefreshWorkflowDraft function', () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
expect(result.current.handleRefreshWorkflowDraft).toBeDefined()
|
||||
expect(typeof result.current.handleRefreshWorkflowDraft).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notUpdateCanvas parameter behavior (THE KEY FIX)', () => {
|
||||
it('should NOT call handleUpdateWorkflowCanvas when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/test-app-id/workflows/draft')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// THE KEY ASSERTION: Canvas should NOT be updated when notUpdateCanvas is true
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleUpdateWorkflowCanvas when notUpdateCanvas is false', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/test-app-id/workflows/draft')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Canvas SHOULD be updated when notUpdateCanvas is false
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'node-1', type: 'start', data: {} }],
|
||||
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleUpdateWorkflowCanvas when notUpdateCanvas is undefined (default)', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Canvas SHOULD be updated when notUpdateCanvas is undefined
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should still update hash even when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// Verify canvas was NOT updated
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still update environment variables when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
|
||||
{ id: 'env-1', name: 'API_KEY', value: '[__HIDDEN__]', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'BASE_URL', value: 'https://api.example.com', value_type: 'string' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still update env secrets when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
|
||||
'env-1': 'secret-key',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still update conversation variables when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([
|
||||
{ id: 'conv-1', name: 'user_input', value: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncing state management', () => {
|
||||
it('should set isSyncingWorkflowDraft to true before fetch', () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should set isSyncingWorkflowDraft to false after fetch completes', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isSyncingWorkflowDraft to false even when fetch fails', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkflowDataLoaded flag management', () => {
|
||||
it('should set isWorkflowDataLoaded to false before fetch when it was true', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockStoreState({ isWorkflowDataLoaded: true }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should set isWorkflowDataLoaded to true after fetch succeeds', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should restore isWorkflowDataLoaded when fetch fails and it was previously loaded', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockStoreState({ isWorkflowDataLoaded: true }),
|
||||
)
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should restore to true because wasLoaded was true
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('debounced sync cancellation', () => {
|
||||
it('should cancel debounced sync before fetching draft', () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockCancelDebouncedSync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle case when debouncedSyncWorkflowDraft has no cancel method', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockStoreState({ debouncedSyncWorkflowDraft: {} }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty graph in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-empty',
|
||||
graph: null,
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing viewport in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-no-viewport',
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1' }],
|
||||
edges: [],
|
||||
viewport: null,
|
||||
},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'node-1' }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing environment_variables in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-no-env',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: undefined,
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing conversation_variables in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-no-conv',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: [],
|
||||
conversation_variables: undefined,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter only secret type for envSecrets', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-mixed-env',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: [
|
||||
{ id: 'env-1', name: 'SECRET_KEY', value: 'secret-value', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
|
||||
{ id: 'env-3', name: 'ANOTHER_SECRET', value: 'another-secret', value_type: 'secret' },
|
||||
],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
|
||||
'env-1': 'secret-value',
|
||||
'env-3': 'another-secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide secret values in environment variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-secrets',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: [
|
||||
{ id: 'env-1', name: 'SECRET_KEY', value: 'super-secret', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
|
||||
],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
|
||||
{ id: 'env-1', name: 'SECRET_KEY', value: '[__HIDDEN__]', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-tab scenario simulation (THE BUG FIX VERIFICATION)', () => {
|
||||
/**
|
||||
* This test verifies the fix for the multi-tab scenario:
|
||||
* 1. User opens workflow in Tab A and Tab B
|
||||
* 2. Tab A saves draft successfully
|
||||
* 3. Tab B tries to save but gets 'draft_workflow_not_sync' error (hash mismatch)
|
||||
* 4. BEFORE FIX: Tab B would fetch draft and overwrite canvas with old data
|
||||
* 5. AFTER FIX: Tab B only updates hash, preserving user's canvas changes
|
||||
*/
|
||||
it('should only update hash when called with notUpdateCanvas=true (simulating sync error recovery)', async () => {
|
||||
const mockResponse = createMockDraftResponse()
|
||||
mockFetchWorkflowDraft.mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
// Simulate the sync error recovery scenario where notUpdateCanvas is true
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Hash should be updated for next sync attempt
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// Canvas should NOT be updated - user's changes are preserved
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
|
||||
// Other states should still be updated
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalled()
|
||||
expect(mockSetConversationVariables).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update canvas when called with notUpdateCanvas=false (normal refresh)', async () => {
|
||||
const mockResponse = createMockDraftResponse()
|
||||
mockFetchWorkflowDraft.mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
// Simulate normal refresh scenario
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// Canvas SHOULD be updated in normal refresh
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -8,7 +8,7 @@ export const useWorkflowRefreshDraft = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const handleRefreshWorkflowDraft = useCallback((notUpdateCanvas?: boolean) => {
|
||||
const handleRefreshWorkflowDraft = useCallback(() => {
|
||||
const {
|
||||
appId,
|
||||
setSyncWorkflowDraftHash,
|
||||
@ -31,14 +31,12 @@ export const useWorkflowRefreshDraft = () => {
|
||||
fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
|
||||
.then((response) => {
|
||||
// Ensure we have a valid workflow structure with viewport
|
||||
if (!notUpdateCanvas) {
|
||||
const workflowData: WorkflowDataUpdater = {
|
||||
nodes: response.graph?.nodes || [],
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
}
|
||||
handleUpdateWorkflowCanvas(workflowData)
|
||||
const workflowData: WorkflowDataUpdater = {
|
||||
nodes: response.graph?.nodes || [],
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
}
|
||||
handleUpdateWorkflowCanvas(workflowData)
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => {
|
||||
acc[env.id] = env.value
|
||||
|
||||
@ -422,16 +422,6 @@
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/debug/debug-with-multiple-model/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 8
|
||||
@ -455,14 +445,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/debug/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 11
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/debug/types.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -2645,19 +2627,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/tools/mcp/mcp-service-card.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/tools/mcp/modal.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 20
|
||||
}
|
||||
},
|
||||
"app/components/tools/mcp/provider-card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
@ -78,8 +78,13 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
|
||||
Element.prototype.scrollIntoView = function () { /* noop */ }
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
afterEach(async () => {
|
||||
// Wrap cleanup in act() to flush pending React scheduler work
|
||||
// This prevents "window is not defined" errors from React 19's scheduler
|
||||
// which uses setImmediate/MessageChannel that can fire after jsdom cleanup
|
||||
await act(async () => {
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
// mock next/image to avoid width/height requirements for data URLs
|
||||
|
||||
Reference in New Issue
Block a user