mirror of
https://github.com/langgenius/dify.git
synced 2026-01-31 16:11:28 +08:00
Merge branch 'main' into feat/hitl-frontend
This commit is contained in:
commit
bf6a2c22eb
@ -65,7 +65,7 @@ jobs:
|
||||
- name: Generate i18n translations
|
||||
if: env.FILES_CHANGED == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
|
||||
run: pnpm run i18n:gen ${{ env.FILE_ARGS }}
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.FILES_CHANGED == 'true'
|
||||
|
||||
@ -20,7 +20,6 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from core.db.session_factory import session_factory
|
||||
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
|
||||
from core.helper.tool_provider_cache import ToolProviderListCache
|
||||
from core.mcp.auth.auth_flow import auth, handle_callback
|
||||
from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError
|
||||
from core.mcp.mcp_client import MCPClient
|
||||
@ -987,9 +986,6 @@ class ToolProviderMCPApi(Resource):
|
||||
# Best-effort: if initial fetch fails (e.g., auth required), return created provider as-is
|
||||
logger.warning("Failed to fetch MCP tools after creation", exc_info=True)
|
||||
|
||||
# Final cache invalidation to ensure list views are up to date
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return jsonable_encoder(result)
|
||||
|
||||
@console_ns.expect(parser_mcp_put)
|
||||
@ -1036,9 +1032,6 @@ class ToolProviderMCPApi(Resource):
|
||||
validation_result=validation_result,
|
||||
)
|
||||
|
||||
# Invalidate cache AFTER transaction commits to avoid holding locks during Redis operations
|
||||
ToolProviderListCache.invalidate_cache(current_tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@console_ns.expect(parser_mcp_delete)
|
||||
@ -1053,9 +1046,6 @@ class ToolProviderMCPApi(Resource):
|
||||
service = MCPToolManageService(session=session)
|
||||
service.delete_provider(tenant_id=current_tenant_id, provider_id=args["provider_id"])
|
||||
|
||||
# Invalidate cache AFTER transaction commits to avoid holding locks during Redis operations
|
||||
ToolProviderListCache.invalidate_cache(current_tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@ -1106,8 +1096,6 @@ class ToolMCPAuthApi(Resource):
|
||||
credentials=provider_entity.credentials,
|
||||
authed=True,
|
||||
)
|
||||
# Invalidate cache after updating credentials
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
return {"result": "success"}
|
||||
except MCPAuthError as e:
|
||||
try:
|
||||
@ -1121,22 +1109,16 @@ class ToolMCPAuthApi(Resource):
|
||||
with Session(db.engine) as session, session.begin():
|
||||
service = MCPToolManageService(session=session)
|
||||
response = service.execute_auth_actions(auth_result)
|
||||
# Invalidate cache after auth actions may have updated provider state
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
return response
|
||||
except MCPRefreshTokenError as e:
|
||||
with Session(db.engine) as session, session.begin():
|
||||
service = MCPToolManageService(session=session)
|
||||
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
|
||||
# Invalidate cache after clearing credentials
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
raise ValueError(f"Failed to refresh token, please try to authorize again: {e}") from e
|
||||
except (MCPError, ValueError) as e:
|
||||
with Session(db.engine) as session, session.begin():
|
||||
service = MCPToolManageService(session=session)
|
||||
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
|
||||
# Invalidate cache after clearing credentials
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
raise ValueError(f"Failed to connect to MCP server: {e}") from e
|
||||
|
||||
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
|
||||
from extensions.ext_redis import redis_client, redis_fallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolProviderListCache:
|
||||
"""Cache for tool provider lists"""
|
||||
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
@staticmethod
|
||||
def _generate_cache_key(tenant_id: str, typ: ToolProviderTypeApiLiteral = None) -> str:
|
||||
"""Generate cache key for tool providers list"""
|
||||
type_filter = typ or "all"
|
||||
return f"tool_providers:tenant_id:{tenant_id}:type:{type_filter}"
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def get_cached_providers(tenant_id: str, typ: ToolProviderTypeApiLiteral = None) -> list[dict[str, Any]] | None:
|
||||
"""Get cached tool providers"""
|
||||
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
|
||||
cached_data = redis_client.get(cache_key)
|
||||
if cached_data:
|
||||
try:
|
||||
return json.loads(cached_data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
logger.warning("Failed to decode cached tool providers data")
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback()
|
||||
def set_cached_providers(tenant_id: str, typ: ToolProviderTypeApiLiteral, providers: list[dict[str, Any]]):
|
||||
"""Cache tool providers"""
|
||||
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
|
||||
redis_client.setex(cache_key, ToolProviderListCache.CACHE_TTL, json.dumps(providers))
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback()
|
||||
def invalidate_cache(tenant_id: str, typ: ToolProviderTypeApiLiteral = None):
|
||||
"""Invalidate cache for tool providers"""
|
||||
if typ:
|
||||
# Invalidate specific type cache
|
||||
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
|
||||
redis_client.delete(cache_key)
|
||||
else:
|
||||
# Invalidate all caches for this tenant
|
||||
keys = ["builtin", "model", "api", "workflow", "mcp"]
|
||||
pipeline = redis_client.pipeline()
|
||||
for key in keys:
|
||||
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, cast(ToolProviderTypeApiLiteral, key))
|
||||
pipeline.delete(cache_key)
|
||||
pipeline.execute()
|
||||
@ -1,4 +1,5 @@
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
@ -36,6 +37,8 @@ default_retrieval_model = {
|
||||
"score_threshold_enabled": False,
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RetrievalService:
|
||||
# Cache precompiled regular expressions to avoid repeated compilation
|
||||
@ -106,7 +109,12 @@ class RetrievalService:
|
||||
)
|
||||
)
|
||||
|
||||
concurrent.futures.wait(futures, timeout=3600, return_when=concurrent.futures.ALL_COMPLETED)
|
||||
if futures:
|
||||
for future in concurrent.futures.as_completed(futures, timeout=3600):
|
||||
if exceptions:
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
if exceptions:
|
||||
raise ValueError(";\n".join(exceptions))
|
||||
@ -210,6 +218,7 @@ class RetrievalService:
|
||||
)
|
||||
all_documents.extend(documents)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
exceptions.append(str(e))
|
||||
|
||||
@classmethod
|
||||
@ -303,6 +312,7 @@ class RetrievalService:
|
||||
else:
|
||||
all_documents.extend(documents)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
exceptions.append(str(e))
|
||||
|
||||
@classmethod
|
||||
@ -351,6 +361,7 @@ class RetrievalService:
|
||||
else:
|
||||
all_documents.extend(documents)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
exceptions.append(str(e))
|
||||
|
||||
@staticmethod
|
||||
@ -663,7 +674,14 @@ class RetrievalService:
|
||||
document_ids_filter=document_ids_filter,
|
||||
)
|
||||
)
|
||||
concurrent.futures.wait(futures, timeout=300, return_when=concurrent.futures.ALL_COMPLETED)
|
||||
# Use as_completed for early error propagation - cancel remaining futures on first error
|
||||
if futures:
|
||||
for future in concurrent.futures.as_completed(futures, timeout=300):
|
||||
if future.exception():
|
||||
# Cancel remaining futures to avoid unnecessary waiting
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
if exceptions:
|
||||
raise ValueError(";\n".join(exceptions))
|
||||
|
||||
@ -516,6 +516,9 @@ class DatasetRetrieval:
|
||||
].embedding_model_provider
|
||||
weights["vector_setting"]["embedding_model_name"] = available_datasets[0].embedding_model
|
||||
with measure_time() as timer:
|
||||
cancel_event = threading.Event()
|
||||
thread_exceptions: list[Exception] = []
|
||||
|
||||
if query:
|
||||
query_thread = threading.Thread(
|
||||
target=self._multiple_retrieve_thread,
|
||||
@ -534,6 +537,8 @@ class DatasetRetrieval:
|
||||
"score_threshold": score_threshold,
|
||||
"query": query,
|
||||
"attachment_id": None,
|
||||
"cancel_event": cancel_event,
|
||||
"thread_exceptions": thread_exceptions,
|
||||
},
|
||||
)
|
||||
all_threads.append(query_thread)
|
||||
@ -557,12 +562,25 @@ class DatasetRetrieval:
|
||||
"score_threshold": score_threshold,
|
||||
"query": None,
|
||||
"attachment_id": attachment_id,
|
||||
"cancel_event": cancel_event,
|
||||
"thread_exceptions": thread_exceptions,
|
||||
},
|
||||
)
|
||||
all_threads.append(attachment_thread)
|
||||
attachment_thread.start()
|
||||
for thread in all_threads:
|
||||
thread.join()
|
||||
|
||||
# Poll threads with short timeout to detect errors quickly (fail-fast)
|
||||
while any(t.is_alive() for t in all_threads):
|
||||
for thread in all_threads:
|
||||
thread.join(timeout=0.1)
|
||||
if thread_exceptions:
|
||||
cancel_event.set()
|
||||
break
|
||||
if thread_exceptions:
|
||||
break
|
||||
|
||||
if thread_exceptions:
|
||||
raise thread_exceptions[0]
|
||||
self._on_query(query, attachment_ids, dataset_ids, app_id, user_from, user_id)
|
||||
|
||||
if all_documents:
|
||||
@ -1404,40 +1422,53 @@ class DatasetRetrieval:
|
||||
score_threshold: float,
|
||||
query: str | None,
|
||||
attachment_id: str | None,
|
||||
cancel_event: threading.Event | None = None,
|
||||
thread_exceptions: list[Exception] | None = None,
|
||||
):
|
||||
with flask_app.app_context():
|
||||
threads = []
|
||||
all_documents_item: list[Document] = []
|
||||
index_type = None
|
||||
for dataset in available_datasets:
|
||||
index_type = dataset.indexing_technique
|
||||
document_ids_filter = None
|
||||
if dataset.provider != "external":
|
||||
if metadata_condition and not metadata_filter_document_ids:
|
||||
continue
|
||||
if metadata_filter_document_ids:
|
||||
document_ids = metadata_filter_document_ids.get(dataset.id, [])
|
||||
if document_ids:
|
||||
document_ids_filter = document_ids
|
||||
else:
|
||||
try:
|
||||
with flask_app.app_context():
|
||||
threads = []
|
||||
all_documents_item: list[Document] = []
|
||||
index_type = None
|
||||
for dataset in available_datasets:
|
||||
# Check for cancellation signal
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
index_type = dataset.indexing_technique
|
||||
document_ids_filter = None
|
||||
if dataset.provider != "external":
|
||||
if metadata_condition and not metadata_filter_document_ids:
|
||||
continue
|
||||
retrieval_thread = threading.Thread(
|
||||
target=self._retriever,
|
||||
kwargs={
|
||||
"flask_app": flask_app,
|
||||
"dataset_id": dataset.id,
|
||||
"query": query,
|
||||
"top_k": top_k,
|
||||
"all_documents": all_documents_item,
|
||||
"document_ids_filter": document_ids_filter,
|
||||
"metadata_condition": metadata_condition,
|
||||
"attachment_ids": [attachment_id] if attachment_id else None,
|
||||
},
|
||||
)
|
||||
threads.append(retrieval_thread)
|
||||
retrieval_thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
if metadata_filter_document_ids:
|
||||
document_ids = metadata_filter_document_ids.get(dataset.id, [])
|
||||
if document_ids:
|
||||
document_ids_filter = document_ids
|
||||
else:
|
||||
continue
|
||||
retrieval_thread = threading.Thread(
|
||||
target=self._retriever,
|
||||
kwargs={
|
||||
"flask_app": flask_app,
|
||||
"dataset_id": dataset.id,
|
||||
"query": query,
|
||||
"top_k": top_k,
|
||||
"all_documents": all_documents_item,
|
||||
"document_ids_filter": document_ids_filter,
|
||||
"metadata_condition": metadata_condition,
|
||||
"attachment_ids": [attachment_id] if attachment_id else None,
|
||||
},
|
||||
)
|
||||
threads.append(retrieval_thread)
|
||||
retrieval_thread.start()
|
||||
|
||||
# Poll threads with short timeout to respond quickly to cancellation
|
||||
while any(t.is_alive() for t in threads):
|
||||
for thread in threads:
|
||||
thread.join(timeout=0.1)
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
|
||||
if reranking_enable:
|
||||
# do rerank for searched documents
|
||||
@ -1470,3 +1501,8 @@ class DatasetRetrieval:
|
||||
all_documents_item = all_documents_item[:top_k] if top_k else all_documents_item
|
||||
if all_documents_item:
|
||||
all_documents.extend(all_documents_item)
|
||||
except Exception as e:
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
if thread_exceptions is not None:
|
||||
thread_exceptions.append(e)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
@ -31,6 +32,11 @@ class BillingService:
|
||||
|
||||
compliance_download_rate_limiter = RateLimiter("compliance_download_rate_limiter", 4, 60)
|
||||
|
||||
# Redis key prefix for tenant plan cache
|
||||
_PLAN_CACHE_KEY_PREFIX = "tenant_plan:"
|
||||
# Cache TTL: 10 minutes
|
||||
_PLAN_CACHE_TTL = 600
|
||||
|
||||
@classmethod
|
||||
def get_info(cls, tenant_id: str):
|
||||
params = {"tenant_id": tenant_id}
|
||||
@ -272,14 +278,110 @@ class BillingService:
|
||||
data = resp.get("data", {})
|
||||
|
||||
for tenant_id, plan in data.items():
|
||||
subscription_plan = subscription_adapter.validate_python(plan)
|
||||
results[tenant_id] = subscription_plan
|
||||
try:
|
||||
subscription_plan = subscription_adapter.validate_python(plan)
|
||||
results[tenant_id] = subscription_plan
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_plan_bulk: failed to validate subscription plan for tenant(%s)", tenant_id
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch billing info batch for tenants: %s", chunk)
|
||||
logger.exception("get_plan_bulk: failed to fetch billing info batch for tenants: %s", chunk)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _make_plan_cache_key(cls, tenant_id: str) -> str:
|
||||
return f"{cls._PLAN_CACHE_KEY_PREFIX}{tenant_id}"
|
||||
|
||||
@classmethod
|
||||
def get_plan_bulk_with_cache(cls, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
|
||||
"""
|
||||
Bulk fetch billing subscription plan with cache to reduce billing API loads in batch job scenarios.
|
||||
|
||||
NOTE: if you want to high data consistency, use get_plan_bulk instead.
|
||||
|
||||
Returns:
|
||||
Mapping of tenant_id -> {plan: str, expiration_date: int}
|
||||
"""
|
||||
tenant_plans: dict[str, SubscriptionPlan] = {}
|
||||
|
||||
if not tenant_ids:
|
||||
return tenant_plans
|
||||
|
||||
subscription_adapter = TypeAdapter(SubscriptionPlan)
|
||||
|
||||
# Step 1: Batch fetch from Redis cache using mget
|
||||
redis_keys = [cls._make_plan_cache_key(tenant_id) for tenant_id in tenant_ids]
|
||||
try:
|
||||
cached_values = redis_client.mget(redis_keys)
|
||||
|
||||
if len(cached_values) != len(tenant_ids):
|
||||
raise Exception(
|
||||
"get_plan_bulk_with_cache: unexpected error: redis mget failed: cached values length mismatch"
|
||||
)
|
||||
|
||||
# Map cached values back to tenant_ids
|
||||
cache_misses: list[str] = []
|
||||
|
||||
for tenant_id, cached_value in zip(tenant_ids, cached_values):
|
||||
if cached_value:
|
||||
try:
|
||||
# Redis returns bytes, decode to string and parse JSON
|
||||
json_str = cached_value.decode("utf-8") if isinstance(cached_value, bytes) else cached_value
|
||||
plan_dict = json.loads(json_str)
|
||||
subscription_plan = subscription_adapter.validate_python(plan_dict)
|
||||
tenant_plans[tenant_id] = subscription_plan
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_plan_bulk_with_cache: process tenant(%s) failed, add to cache misses", tenant_id
|
||||
)
|
||||
cache_misses.append(tenant_id)
|
||||
else:
|
||||
cache_misses.append(tenant_id)
|
||||
|
||||
logger.info(
|
||||
"get_plan_bulk_with_cache: cache hits=%s, cache misses=%s",
|
||||
len(tenant_plans),
|
||||
len(cache_misses),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("get_plan_bulk_with_cache: redis mget failed, falling back to API")
|
||||
cache_misses = list(tenant_ids)
|
||||
|
||||
# Step 2: Fetch missing plans from billing API
|
||||
if cache_misses:
|
||||
bulk_plans = BillingService.get_plan_bulk(cache_misses)
|
||||
|
||||
if bulk_plans:
|
||||
plans_to_cache: dict[str, SubscriptionPlan] = {}
|
||||
|
||||
for tenant_id, subscription_plan in bulk_plans.items():
|
||||
tenant_plans[tenant_id] = subscription_plan
|
||||
plans_to_cache[tenant_id] = subscription_plan
|
||||
|
||||
# Step 3: Batch update Redis cache using pipeline
|
||||
if plans_to_cache:
|
||||
try:
|
||||
pipe = redis_client.pipeline()
|
||||
for tenant_id, subscription_plan in plans_to_cache.items():
|
||||
redis_key = cls._make_plan_cache_key(tenant_id)
|
||||
# Serialize dict to JSON string
|
||||
json_str = json.dumps(subscription_plan)
|
||||
pipe.setex(redis_key, cls._PLAN_CACHE_TTL, json_str)
|
||||
pipe.execute()
|
||||
|
||||
logger.info(
|
||||
"get_plan_bulk_with_cache: cached %s new tenant plans to Redis",
|
||||
len(plans_to_cache),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("get_plan_bulk_with_cache: redis pipeline failed")
|
||||
|
||||
return tenant_plans
|
||||
|
||||
@classmethod
|
||||
def get_expired_subscription_cleanup_whitelist(cls) -> Sequence[str]:
|
||||
resp = cls._send_request("GET", "/subscription/cleanup/whitelist")
|
||||
|
||||
@ -7,7 +7,6 @@ from httpx import get
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.entities.provider_entities import ProviderConfig
|
||||
from core.helper.tool_provider_cache import ToolProviderListCache
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.custom_tool.provider import ApiToolProviderController
|
||||
@ -178,9 +177,6 @@ class ApiToolManageService:
|
||||
# update labels
|
||||
ToolLabelManager.update_tool_labels(provider_controller, labels)
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
@ -322,9 +318,6 @@ class ApiToolManageService:
|
||||
# update labels
|
||||
ToolLabelManager.update_tool_labels(provider_controller, labels)
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
@ -347,9 +340,6 @@ class ApiToolManageService:
|
||||
db.session.delete(provider)
|
||||
db.session.commit()
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -12,7 +12,6 @@ from constants import HIDDEN_VALUE, UNKNOWN_VALUE
|
||||
from core.helper.name_generator import generate_incremental_name
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache
|
||||
from core.helper.tool_provider_cache import ToolProviderListCache
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
@ -205,9 +204,6 @@ class BuiltinToolManageService:
|
||||
db_provider.name = name
|
||||
|
||||
session.commit()
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise ValueError(str(e))
|
||||
@ -290,8 +286,6 @@ class BuiltinToolManageService:
|
||||
session.rollback()
|
||||
raise ValueError(str(e))
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id, "builtin")
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
@ -409,9 +403,6 @@ class BuiltinToolManageService:
|
||||
)
|
||||
cache.delete()
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
@ -434,8 +425,6 @@ class BuiltinToolManageService:
|
||||
target_provider.is_default = True
|
||||
session.commit()
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
from core.helper.tool_provider_cache import ToolProviderListCache
|
||||
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
@ -16,14 +15,6 @@ class ToolCommonService:
|
||||
|
||||
:return: the list of tool providers
|
||||
"""
|
||||
# Try to get from cache first
|
||||
cached_result = ToolProviderListCache.get_cached_providers(tenant_id, typ)
|
||||
if cached_result is not None:
|
||||
logger.debug("Returning cached tool providers for tenant %s, type %s", tenant_id, typ)
|
||||
return cached_result
|
||||
|
||||
# Cache miss - fetch from database
|
||||
logger.debug("Cache miss for tool providers, fetching from database for tenant %s, type %s", tenant_id, typ)
|
||||
providers = ToolManager.list_providers_from_api(user_id, tenant_id, typ)
|
||||
|
||||
# add icon
|
||||
@ -32,7 +23,4 @@ class ToolCommonService:
|
||||
|
||||
result = [provider.to_dict() for provider in providers]
|
||||
|
||||
# Cache the result
|
||||
ToolProviderListCache.set_cached_providers(tenant_id, typ, result)
|
||||
|
||||
return result
|
||||
|
||||
@ -5,9 +5,8 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from core.helper.tool_provider_cache import ToolProviderListCache
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
from core.tools.__base.tool_provider import ToolProviderController
|
||||
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
|
||||
@ -86,17 +85,13 @@ class WorkflowToolManageService:
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
with Session(db.engine, expire_on_commit=False) as session, session.begin():
|
||||
session.add(workflow_tool_provider)
|
||||
|
||||
if labels is not None:
|
||||
ToolLabelManager.update_tool_labels(
|
||||
ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels
|
||||
)
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
@ -184,9 +179,6 @@ class WorkflowToolManageService:
|
||||
ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels
|
||||
)
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
@ -249,9 +241,6 @@ class WorkflowToolManageService:
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Invalidate tool providers cache
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -0,0 +1,365 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
class TestBillingServiceGetPlanBulkWithCache:
|
||||
"""
|
||||
Comprehensive integration tests for get_plan_bulk_with_cache using testcontainers.
|
||||
|
||||
This test class covers all major scenarios:
|
||||
- Cache hit/miss scenarios
|
||||
- Redis operation failures and fallback behavior
|
||||
- Invalid cache data handling
|
||||
- TTL expiration handling
|
||||
- Error recovery and logging
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_redis_cleanup(self, flask_app_with_containers):
|
||||
"""Clean up Redis cache before and after each test."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Clean up before test
|
||||
yield
|
||||
# Clean up after test
|
||||
# Delete all test cache keys
|
||||
pattern = f"{BillingService._PLAN_CACHE_KEY_PREFIX}*"
|
||||
keys = redis_client.keys(pattern)
|
||||
if keys:
|
||||
redis_client.delete(*keys)
|
||||
|
||||
def _create_test_plan_data(self, plan: str = "sandbox", expiration_date: int = 1735689600):
|
||||
"""Helper to create test SubscriptionPlan data."""
|
||||
return {"plan": plan, "expiration_date": expiration_date}
|
||||
|
||||
def _set_cache(self, tenant_id: str, plan_data: dict, ttl: int = 600):
|
||||
"""Helper to set cache data in Redis."""
|
||||
cache_key = BillingService._make_plan_cache_key(tenant_id)
|
||||
json_str = json.dumps(plan_data)
|
||||
redis_client.setex(cache_key, ttl, json_str)
|
||||
|
||||
def _get_cache(self, tenant_id: str):
|
||||
"""Helper to get cache data from Redis."""
|
||||
cache_key = BillingService._make_plan_cache_key(tenant_id)
|
||||
value = redis_client.get(cache_key)
|
||||
if value:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return value
|
||||
return None
|
||||
|
||||
def test_get_plan_bulk_with_cache_all_cache_hit(self, flask_app_with_containers):
|
||||
"""Test bulk plan retrieval when all tenants are in cache."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
|
||||
expected_plans = {
|
||||
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
"tenant-3": self._create_test_plan_data("team", 1798761600),
|
||||
}
|
||||
|
||||
# Pre-populate cache
|
||||
for tenant_id, plan_data in expected_plans.items():
|
||||
self._set_cache(tenant_id, plan_data)
|
||||
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk") as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert result["tenant-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-1"]["expiration_date"] == 1735689600
|
||||
assert result["tenant-2"]["plan"] == "professional"
|
||||
assert result["tenant-2"]["expiration_date"] == 1767225600
|
||||
assert result["tenant-3"]["plan"] == "team"
|
||||
assert result["tenant-3"]["expiration_date"] == 1798761600
|
||||
|
||||
# Verify API was not called
|
||||
mock_get_plan_bulk.assert_not_called()
|
||||
|
||||
def test_get_plan_bulk_with_cache_all_cache_miss(self, flask_app_with_containers):
|
||||
"""Test bulk plan retrieval when all tenants are not in cache."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2"]
|
||||
expected_plans = {
|
||||
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert result["tenant-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-2"]["plan"] == "professional"
|
||||
|
||||
# Verify API was called with correct tenant_ids
|
||||
mock_get_plan_bulk.assert_called_once_with(tenant_ids)
|
||||
|
||||
# Verify data was written to cache
|
||||
cached_1 = self._get_cache("tenant-1")
|
||||
cached_2 = self._get_cache("tenant-2")
|
||||
assert cached_1 is not None
|
||||
assert cached_2 is not None
|
||||
|
||||
# Verify cache content
|
||||
cached_data_1 = json.loads(cached_1)
|
||||
cached_data_2 = json.loads(cached_2)
|
||||
assert cached_data_1 == expected_plans["tenant-1"]
|
||||
assert cached_data_2 == expected_plans["tenant-2"]
|
||||
|
||||
# Verify TTL is set
|
||||
cache_key_1 = BillingService._make_plan_cache_key("tenant-1")
|
||||
ttl_1 = redis_client.ttl(cache_key_1)
|
||||
assert ttl_1 > 0
|
||||
assert ttl_1 <= 600 # Should be <= 600 seconds
|
||||
|
||||
def test_get_plan_bulk_with_cache_partial_cache_hit(self, flask_app_with_containers):
|
||||
"""Test bulk plan retrieval when some tenants are in cache, some are not."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
|
||||
# Pre-populate cache for tenant-1 and tenant-2
|
||||
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600))
|
||||
self._set_cache("tenant-2", self._create_test_plan_data("professional", 1767225600))
|
||||
|
||||
# tenant-3 is not in cache
|
||||
missing_plan = {"tenant-3": self._create_test_plan_data("team", 1798761600)}
|
||||
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk", return_value=missing_plan) as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert result["tenant-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-2"]["plan"] == "professional"
|
||||
assert result["tenant-3"]["plan"] == "team"
|
||||
|
||||
# Verify API was called only for missing tenant
|
||||
mock_get_plan_bulk.assert_called_once_with(["tenant-3"])
|
||||
|
||||
# Verify tenant-3 data was written to cache
|
||||
cached_3 = self._get_cache("tenant-3")
|
||||
assert cached_3 is not None
|
||||
cached_data_3 = json.loads(cached_3)
|
||||
assert cached_data_3 == missing_plan["tenant-3"]
|
||||
|
||||
def test_get_plan_bulk_with_cache_redis_mget_failure(self, flask_app_with_containers):
|
||||
"""Test fallback to API when Redis mget fails."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2"]
|
||||
expected_plans = {
|
||||
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
}
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(redis_client, "mget", side_effect=Exception("Redis connection error")),
|
||||
patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk,
|
||||
):
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert result["tenant-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-2"]["plan"] == "professional"
|
||||
|
||||
# Verify API was called for all tenants (fallback)
|
||||
mock_get_plan_bulk.assert_called_once_with(tenant_ids)
|
||||
|
||||
# Verify data was written to cache after fallback
|
||||
cached_1 = self._get_cache("tenant-1")
|
||||
cached_2 = self._get_cache("tenant-2")
|
||||
assert cached_1 is not None
|
||||
assert cached_2 is not None
|
||||
|
||||
def test_get_plan_bulk_with_cache_invalid_json_in_cache(self, flask_app_with_containers):
|
||||
"""Test fallback to API when cache contains invalid JSON."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
|
||||
|
||||
# Set valid cache for tenant-1
|
||||
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600))
|
||||
|
||||
# Set invalid JSON for tenant-2
|
||||
cache_key_2 = BillingService._make_plan_cache_key("tenant-2")
|
||||
redis_client.setex(cache_key_2, 600, "invalid json {")
|
||||
|
||||
# tenant-3 is not in cache
|
||||
expected_plans = {
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
"tenant-3": self._create_test_plan_data("team", 1798761600),
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert result["tenant-1"]["plan"] == "sandbox" # From cache
|
||||
assert result["tenant-2"]["plan"] == "professional" # From API (fallback)
|
||||
assert result["tenant-3"]["plan"] == "team" # From API
|
||||
|
||||
# Verify API was called for tenant-2 and tenant-3
|
||||
mock_get_plan_bulk.assert_called_once_with(["tenant-2", "tenant-3"])
|
||||
|
||||
# Verify tenant-2's invalid JSON was replaced with correct data in cache
|
||||
cached_2 = self._get_cache("tenant-2")
|
||||
assert cached_2 is not None
|
||||
cached_data_2 = json.loads(cached_2)
|
||||
assert cached_data_2 == expected_plans["tenant-2"]
|
||||
assert cached_data_2["plan"] == "professional"
|
||||
assert cached_data_2["expiration_date"] == 1767225600
|
||||
|
||||
# Verify tenant-2 cache has correct TTL
|
||||
cache_key_2_new = BillingService._make_plan_cache_key("tenant-2")
|
||||
ttl_2 = redis_client.ttl(cache_key_2_new)
|
||||
assert ttl_2 > 0
|
||||
assert ttl_2 <= 600
|
||||
|
||||
# Verify tenant-3 data was also written to cache
|
||||
cached_3 = self._get_cache("tenant-3")
|
||||
assert cached_3 is not None
|
||||
cached_data_3 = json.loads(cached_3)
|
||||
assert cached_data_3 == expected_plans["tenant-3"]
|
||||
|
||||
def test_get_plan_bulk_with_cache_invalid_plan_data_in_cache(self, flask_app_with_containers):
|
||||
"""Test fallback to API when cache data doesn't match SubscriptionPlan schema."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2", "tenant-3"]
|
||||
|
||||
# Set valid cache for tenant-1
|
||||
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600))
|
||||
|
||||
# Set invalid plan data for tenant-2 (missing expiration_date)
|
||||
cache_key_2 = BillingService._make_plan_cache_key("tenant-2")
|
||||
invalid_data = json.dumps({"plan": "professional"}) # Missing expiration_date
|
||||
redis_client.setex(cache_key_2, 600, invalid_data)
|
||||
|
||||
# tenant-3 is not in cache
|
||||
expected_plans = {
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
"tenant-3": self._create_test_plan_data("team", 1798761600),
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert result["tenant-1"]["plan"] == "sandbox" # From cache
|
||||
assert result["tenant-2"]["plan"] == "professional" # From API (fallback)
|
||||
assert result["tenant-3"]["plan"] == "team" # From API
|
||||
|
||||
# Verify API was called for tenant-2 and tenant-3
|
||||
mock_get_plan_bulk.assert_called_once_with(["tenant-2", "tenant-3"])
|
||||
|
||||
def test_get_plan_bulk_with_cache_redis_pipeline_failure(self, flask_app_with_containers):
|
||||
"""Test that pipeline failure doesn't affect return value."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2"]
|
||||
expected_plans = {
|
||||
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
}
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(BillingService, "get_plan_bulk", return_value=expected_plans),
|
||||
patch.object(redis_client, "pipeline") as mock_pipeline,
|
||||
):
|
||||
# Create a mock pipeline that fails on execute
|
||||
mock_pipe = mock_pipeline.return_value
|
||||
mock_pipe.execute.side_effect = Exception("Pipeline execution failed")
|
||||
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert - Function should still return correct result despite pipeline failure
|
||||
assert len(result) == 2
|
||||
assert result["tenant-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-2"]["plan"] == "professional"
|
||||
|
||||
# Verify pipeline was attempted
|
||||
mock_pipeline.assert_called_once()
|
||||
|
||||
def test_get_plan_bulk_with_cache_empty_tenant_ids(self, flask_app_with_containers):
|
||||
"""Test with empty tenant_ids list."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk") as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache([])
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
assert len(result) == 0
|
||||
|
||||
# Verify no API calls
|
||||
mock_get_plan_bulk.assert_not_called()
|
||||
|
||||
# Verify no Redis operations (mget with empty list would return empty list)
|
||||
# But we should check that mget was not called at all
|
||||
# Since we can't easily verify this without more mocking, we just verify the result
|
||||
|
||||
def test_get_plan_bulk_with_cache_ttl_expired(self, flask_app_with_containers):
|
||||
"""Test that expired cache keys are treated as cache misses."""
|
||||
with flask_app_with_containers.app_context():
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2"]
|
||||
|
||||
# Set cache for tenant-1 with very short TTL (1 second) to simulate expiration
|
||||
self._set_cache("tenant-1", self._create_test_plan_data("sandbox", 1735689600), ttl=1)
|
||||
|
||||
# Wait for TTL to expire (key will be deleted by Redis)
|
||||
import time
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Verify cache is expired (key doesn't exist)
|
||||
cache_key_1 = BillingService._make_plan_cache_key("tenant-1")
|
||||
exists = redis_client.exists(cache_key_1)
|
||||
assert exists == 0 # Key doesn't exist (expired)
|
||||
|
||||
# tenant-2 is not in cache
|
||||
expected_plans = {
|
||||
"tenant-1": self._create_test_plan_data("sandbox", 1735689600),
|
||||
"tenant-2": self._create_test_plan_data("professional", 1767225600),
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch.object(BillingService, "get_plan_bulk", return_value=expected_plans) as mock_get_plan_bulk:
|
||||
result = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert result["tenant-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-2"]["plan"] == "professional"
|
||||
|
||||
# Verify API was called for both tenants (tenant-1 expired, tenant-2 missing)
|
||||
mock_get_plan_bulk.assert_called_once_with(tenant_ids)
|
||||
|
||||
# Verify both were written to cache with correct TTL
|
||||
cache_key_1_new = BillingService._make_plan_cache_key("tenant-1")
|
||||
cache_key_2 = BillingService._make_plan_cache_key("tenant-2")
|
||||
ttl_1_new = redis_client.ttl(cache_key_1_new)
|
||||
ttl_2 = redis_client.ttl(cache_key_2)
|
||||
assert ttl_1_new > 0
|
||||
assert ttl_1_new <= 600
|
||||
assert ttl_2 > 0
|
||||
assert ttl_2 <= 600
|
||||
@ -41,13 +41,10 @@ def client():
|
||||
@patch(
|
||||
"controllers.console.workspace.tool_providers.current_account_with_tenant", return_value=(MagicMock(id="u1"), "t1")
|
||||
)
|
||||
@patch("controllers.console.workspace.tool_providers.ToolProviderListCache.invalidate_cache", return_value=None)
|
||||
@patch("controllers.console.workspace.tool_providers.Session")
|
||||
@patch("controllers.console.workspace.tool_providers.MCPToolManageService._reconnect_with_url")
|
||||
@pytest.mark.usefixtures("_mock_cache", "_mock_user_tenant")
|
||||
def test_create_mcp_provider_populates_tools(
|
||||
mock_reconnect, mock_session, mock_invalidate_cache, mock_current_account_with_tenant, client
|
||||
):
|
||||
def test_create_mcp_provider_populates_tools(mock_reconnect, mock_session, mock_current_account_with_tenant, client):
|
||||
# Arrange: reconnect returns tools immediately
|
||||
mock_reconnect.return_value = ReconnectResult(
|
||||
authed=True,
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from core.helper.tool_provider_cache import ToolProviderListCache
|
||||
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client():
|
||||
"""Fixture: Mock Redis client"""
|
||||
with patch("core.helper.tool_provider_cache.redis_client") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
class TestToolProviderListCache:
|
||||
"""Test class for ToolProviderListCache"""
|
||||
|
||||
def test_generate_cache_key(self):
|
||||
"""Test cache key generation logic"""
|
||||
# Scenario 1: Specify typ (valid literal value)
|
||||
tenant_id = "tenant_123"
|
||||
typ: ToolProviderTypeApiLiteral = "builtin"
|
||||
expected_key = f"tool_providers:tenant_id:{tenant_id}:type:{typ}"
|
||||
assert ToolProviderListCache._generate_cache_key(tenant_id, typ) == expected_key
|
||||
|
||||
# Scenario 2: typ is None (defaults to "all")
|
||||
expected_key_all = f"tool_providers:tenant_id:{tenant_id}:type:all"
|
||||
assert ToolProviderListCache._generate_cache_key(tenant_id) == expected_key_all
|
||||
|
||||
def test_get_cached_providers_hit(self, mock_redis_client):
|
||||
"""Test get cached providers - cache hit and successful decoding"""
|
||||
tenant_id = "tenant_123"
|
||||
typ: ToolProviderTypeApiLiteral = "api"
|
||||
mock_providers = [{"id": "tool", "name": "test_provider"}]
|
||||
mock_redis_client.get.return_value = json.dumps(mock_providers).encode("utf-8")
|
||||
|
||||
result = ToolProviderListCache.get_cached_providers(tenant_id, typ)
|
||||
|
||||
mock_redis_client.get.assert_called_once_with(ToolProviderListCache._generate_cache_key(tenant_id, typ))
|
||||
assert result == mock_providers
|
||||
|
||||
def test_get_cached_providers_decode_error(self, mock_redis_client):
|
||||
"""Test get cached providers - cache hit but decoding failed"""
|
||||
tenant_id = "tenant_123"
|
||||
mock_redis_client.get.return_value = b"invalid_json_data"
|
||||
|
||||
result = ToolProviderListCache.get_cached_providers(tenant_id)
|
||||
|
||||
assert result is None
|
||||
mock_redis_client.get.assert_called_once()
|
||||
|
||||
def test_get_cached_providers_miss(self, mock_redis_client):
|
||||
"""Test get cached providers - cache miss"""
|
||||
tenant_id = "tenant_123"
|
||||
mock_redis_client.get.return_value = None
|
||||
|
||||
result = ToolProviderListCache.get_cached_providers(tenant_id)
|
||||
|
||||
assert result is None
|
||||
mock_redis_client.get.assert_called_once()
|
||||
|
||||
def test_set_cached_providers(self, mock_redis_client):
|
||||
"""Test set cached providers"""
|
||||
tenant_id = "tenant_123"
|
||||
typ: ToolProviderTypeApiLiteral = "builtin"
|
||||
mock_providers = [{"id": "tool", "name": "test_provider"}]
|
||||
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
|
||||
|
||||
ToolProviderListCache.set_cached_providers(tenant_id, typ, mock_providers)
|
||||
|
||||
mock_redis_client.setex.assert_called_once_with(
|
||||
cache_key, ToolProviderListCache.CACHE_TTL, json.dumps(mock_providers)
|
||||
)
|
||||
|
||||
def test_invalidate_cache_specific_type(self, mock_redis_client):
|
||||
"""Test invalidate cache - specific type"""
|
||||
tenant_id = "tenant_123"
|
||||
typ: ToolProviderTypeApiLiteral = "workflow"
|
||||
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, typ)
|
||||
|
||||
ToolProviderListCache.invalidate_cache(tenant_id, typ)
|
||||
|
||||
mock_redis_client.delete.assert_called_once_with(cache_key)
|
||||
|
||||
def test_invalidate_cache_all_types(self, mock_redis_client):
|
||||
"""Test invalidate cache - clear all tenant cache"""
|
||||
tenant_id = "tenant_123"
|
||||
mock_keys = [
|
||||
b"tool_providers:tenant_id:tenant_123:type:all",
|
||||
b"tool_providers:tenant_id:tenant_123:type:builtin",
|
||||
]
|
||||
mock_redis_client.scan_iter.return_value = mock_keys
|
||||
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
def test_invalidate_cache_no_keys(self, mock_redis_client):
|
||||
"""Test invalidate cache - no cache keys for tenant"""
|
||||
tenant_id = "tenant_123"
|
||||
mock_redis_client.scan_iter.return_value = []
|
||||
|
||||
ToolProviderListCache.invalidate_cache(tenant_id)
|
||||
|
||||
mock_redis_client.delete.assert_not_called()
|
||||
|
||||
def test_redis_fallback_default_return(self, mock_redis_client):
|
||||
"""Test redis_fallback decorator - default return value (Redis error)"""
|
||||
mock_redis_client.get.side_effect = RedisError("Redis connection error")
|
||||
|
||||
result = ToolProviderListCache.get_cached_providers("tenant_123")
|
||||
|
||||
assert result is None
|
||||
mock_redis_client.get.assert_called_once()
|
||||
|
||||
def test_redis_fallback_no_default(self, mock_redis_client):
|
||||
"""Test redis_fallback decorator - no default return value (Redis error)"""
|
||||
mock_redis_client.setex.side_effect = RedisError("Redis connection error")
|
||||
|
||||
try:
|
||||
ToolProviderListCache.set_cached_providers("tenant_123", "mcp", [])
|
||||
except RedisError:
|
||||
pytest.fail("set_cached_providers should not raise RedisError (handled by fallback)")
|
||||
|
||||
mock_redis_client.setex.assert_called_once()
|
||||
@ -421,7 +421,18 @@ class TestRetrievalService:
|
||||
# In real code, this waits for all futures to complete
|
||||
# In tests, futures complete immediately, so wait is a no-op
|
||||
with patch("core.rag.datasource.retrieval_service.concurrent.futures.wait"):
|
||||
yield mock_executor
|
||||
# Mock concurrent.futures.as_completed for early error propagation
|
||||
# In real code, this yields futures as they complete
|
||||
# In tests, we yield all futures immediately since they're already done
|
||||
def mock_as_completed(futures_list, timeout=None):
|
||||
"""Mock as_completed that yields futures immediately."""
|
||||
yield from futures_list
|
||||
|
||||
with patch(
|
||||
"core.rag.datasource.retrieval_service.concurrent.futures.as_completed",
|
||||
side_effect=mock_as_completed,
|
||||
):
|
||||
yield mock_executor
|
||||
|
||||
# ==================== Vector Search Tests ====================
|
||||
|
||||
|
||||
@ -1294,6 +1294,42 @@ class TestBillingServiceSubscriptionOperations:
|
||||
# Assert
|
||||
assert result == {}
|
||||
|
||||
def test_get_plan_bulk_with_invalid_tenant_plan_skipped(self, mock_send_request):
|
||||
"""Test bulk plan retrieval when one tenant has invalid plan data (should skip that tenant)."""
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-valid-1", "tenant-invalid", "tenant-valid-2"]
|
||||
|
||||
# Response with one invalid tenant plan (missing expiration_date) and two valid ones
|
||||
mock_send_request.return_value = {
|
||||
"data": {
|
||||
"tenant-valid-1": {"plan": "sandbox", "expiration_date": 1735689600},
|
||||
"tenant-invalid": {"plan": "professional"}, # Missing expiration_date field
|
||||
"tenant-valid-2": {"plan": "team", "expiration_date": 1767225600},
|
||||
}
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch("services.billing_service.logger") as mock_logger:
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert - should only contain valid tenants
|
||||
assert len(result) == 2
|
||||
assert "tenant-valid-1" in result
|
||||
assert "tenant-valid-2" in result
|
||||
assert "tenant-invalid" not in result
|
||||
|
||||
# Verify valid tenants have correct data
|
||||
assert result["tenant-valid-1"]["plan"] == "sandbox"
|
||||
assert result["tenant-valid-1"]["expiration_date"] == 1735689600
|
||||
assert result["tenant-valid-2"]["plan"] == "team"
|
||||
assert result["tenant-valid-2"]["expiration_date"] == 1767225600
|
||||
|
||||
# Verify exception was logged for the invalid tenant
|
||||
mock_logger.exception.assert_called_once()
|
||||
log_call_args = mock_logger.exception.call_args[0]
|
||||
assert "get_plan_bulk: failed to validate subscription plan for tenant" in log_call_args[0]
|
||||
assert "tenant-invalid" in log_call_args[1]
|
||||
|
||||
def test_get_expired_subscription_cleanup_whitelist_success(self, mock_send_request):
|
||||
"""Test successful retrieval of expired subscription cleanup whitelist."""
|
||||
# Arrange
|
||||
|
||||
@ -3,7 +3,7 @@ import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import { transpile } from 'typescript'
|
||||
|
||||
describe('check-i18n script functionality', () => {
|
||||
describe('i18n:check script functionality', () => {
|
||||
const testDir = path.join(__dirname, '../i18n-test')
|
||||
const testEnDir = path.join(testDir, 'en-US')
|
||||
const testZhDir = path.join(testDir, 'zh-Hans')
|
||||
|
||||
394
web/app/components/app/configuration/config-var/index.spec.tsx
Normal file
394
web/app/components/app/configuration/config-var/index.spec.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IConfigVarProps } from './index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
|
||||
|
||||
const notifySpy = vi.spyOn(Toast, 'notify').mockImplementation(vi.fn())
|
||||
|
||||
const setShowExternalDataToolModal = vi.fn()
|
||||
|
||||
type SubscriptionEvent = {
|
||||
type: string
|
||||
payload: ExternalDataTool
|
||||
}
|
||||
|
||||
let subscriptionCallback: ((event: SubscriptionEvent) => void) | null = null
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
useSubscription: (callback: (event: SubscriptionEvent) => void) => {
|
||||
subscriptionCallback = callback
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalDataToolModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
type SortableItem = {
|
||||
id: string
|
||||
variable: PromptVariable
|
||||
}
|
||||
|
||||
type SortableProps = {
|
||||
list: SortableItem[]
|
||||
setList: (list: SortableItem[]) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
let latestSortableProps: SortableProps | null = null
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: (props: SortableProps) => {
|
||||
latestSortableProps = props
|
||||
return <div data-testid="sortable">{props.children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
type DebugConfigurationState = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value']
|
||||
|
||||
const defaultDebugConfigValue = {
|
||||
mode: AppModeEnum.CHAT,
|
||||
dataSets: [],
|
||||
modelConfig: {
|
||||
model_id: 'test-model',
|
||||
},
|
||||
} as unknown as DebugConfigurationState
|
||||
|
||||
const createDebugConfigValue = (overrides: Partial<DebugConfigurationState> = {}): DebugConfigurationState => ({
|
||||
...defaultDebugConfigValue,
|
||||
...overrides,
|
||||
} as unknown as DebugConfigurationState)
|
||||
|
||||
let variableIndex = 0
|
||||
const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVariable => {
|
||||
variableIndex += 1
|
||||
return {
|
||||
key: `var_${variableIndex}`,
|
||||
name: `Variable ${variableIndex}`,
|
||||
type: 'string',
|
||||
required: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: Partial<DebugConfigurationState> = {}) => {
|
||||
const defaultProps: IConfigVarProps = {
|
||||
promptVariables: [],
|
||||
readonly: false,
|
||||
onPromptVariablesChange: vi.fn(),
|
||||
}
|
||||
|
||||
const mergedProps = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
}
|
||||
|
||||
return render(
|
||||
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
|
||||
<ConfigVar {...mergedProps} />
|
||||
</DebugConfigurationContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConfigVar', () => {
|
||||
// Rendering behavior for empty and populated states.
|
||||
describe('ConfigVar Rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestSortableProps = null
|
||||
subscriptionCallback = null
|
||||
variableIndex = 0
|
||||
notifySpy.mockClear()
|
||||
})
|
||||
|
||||
it('should show empty state when no variables exist', () => {
|
||||
renderConfigVar({ promptVariables: [] })
|
||||
|
||||
expect(screen.getByText('appDebug.notSetVar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render variable items and allow reordering via sortable list', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
|
||||
const secondVar = createPromptVariable({ key: 'second', name: 'Second' })
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [firstVar, secondVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
expect(screen.getByText('first')).toBeInTheDocument()
|
||||
expect(screen.getByText('second')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
latestSortableProps?.setList([
|
||||
{ id: 'second', variable: secondVar },
|
||||
{ id: 'first', variable: firstVar },
|
||||
])
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar])
|
||||
})
|
||||
})
|
||||
|
||||
// Variable creation flows using the add menu.
|
||||
describe('ConfigVar Add Variable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestSortableProps = null
|
||||
subscriptionCallback = null
|
||||
variableIndex = 0
|
||||
notifySpy.mockClear()
|
||||
})
|
||||
|
||||
it('should add a text variable when selecting the string option', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
renderConfigVar({ promptVariables: [], onPromptVariablesChange })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.add'))
|
||||
fireEvent.click(await screen.findByText('appDebug.variableConfig.string'))
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
|
||||
const [nextVariables] = onPromptVariablesChange.mock.calls[0]
|
||||
expect(nextVariables).toHaveLength(1)
|
||||
expect(nextVariables[0].type).toBe('string')
|
||||
})
|
||||
|
||||
it('should open the external data tool modal when adding an api variable', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
renderConfigVar({ promptVariables: [], onPromptVariablesChange })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.add'))
|
||||
fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar'))
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
|
||||
expect(setShowExternalDataToolModal).toHaveBeenCalledTimes(1)
|
||||
|
||||
const modalState = setShowExternalDataToolModal.mock.calls[0][0]
|
||||
expect(modalState.payload.type).toBe('api')
|
||||
|
||||
act(() => {
|
||||
modalState.onCancelCallback?.()
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenLastCalledWith([])
|
||||
})
|
||||
|
||||
it('should restore previous variables when cancelling api variable with existing items', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const existingVar = createPromptVariable({ key: 'existing', name: 'Existing' })
|
||||
|
||||
renderConfigVar({ promptVariables: [existingVar], onPromptVariablesChange })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.add'))
|
||||
fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar'))
|
||||
|
||||
const modalState = setShowExternalDataToolModal.mock.calls[0][0]
|
||||
act(() => {
|
||||
modalState.onCancelCallback?.()
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledTimes(2)
|
||||
const [addedVariables] = onPromptVariablesChange.mock.calls[0]
|
||||
expect(addedVariables).toHaveLength(2)
|
||||
expect(addedVariables[0]).toBe(existingVar)
|
||||
expect(addedVariables[1].type).toBe('api')
|
||||
expect(onPromptVariablesChange).toHaveBeenLastCalledWith([existingVar])
|
||||
})
|
||||
})
|
||||
|
||||
// Editing flows for variables through the modal.
|
||||
describe('ConfigVar Edit Variable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestSortableProps = null
|
||||
subscriptionCallback = null
|
||||
variableIndex = 0
|
||||
notifySpy.mockClear()
|
||||
})
|
||||
|
||||
it('should save updates when editing a basic variable', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const variable = createPromptVariable({ key: 'name', name: 'Name' })
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [variable],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('name · Name')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show error when variable key is duplicated', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
|
||||
const secondVar = createPromptVariable({ key: 'second', name: 'Second' })
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [firstVar, secondVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('first · First')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder')
|
||||
fireEvent.change(inputs[0], { target: { value: 'second' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
expect(onPromptVariablesChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when variable label is duplicated', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
|
||||
const secondVar = createPromptVariable({ key: 'second', name: 'Second' })
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [firstVar, secondVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('first · First')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder')
|
||||
fireEvent.change(inputs[1], { target: { value: 'Second' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
expect(onPromptVariablesChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Removal behavior including confirm modal branch.
|
||||
describe('ConfigVar Remove Variable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestSortableProps = null
|
||||
subscriptionCallback = null
|
||||
variableIndex = 0
|
||||
notifySpy.mockClear()
|
||||
})
|
||||
|
||||
it('should remove variable directly when context confirmation is not required', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const variable = createPromptVariable({ key: 'name', name: 'Name' })
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [variable],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const removeBtn = screen.getByTestId('var-item-delete-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should require confirmation when removing context variable with datasets in completion mode', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const variable = createPromptVariable({
|
||||
key: 'context',
|
||||
name: 'Context',
|
||||
is_context_var: true,
|
||||
})
|
||||
|
||||
renderConfigVar(
|
||||
{
|
||||
promptVariables: [variable],
|
||||
onPromptVariablesChange,
|
||||
},
|
||||
{
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
dataSets: [{ id: 'dataset-1' } as DebugConfigurationState['dataSets'][number]],
|
||||
},
|
||||
)
|
||||
|
||||
const deleteBtn = screen.getByTestId('var-item-delete-btn')
|
||||
fireEvent.click(deleteBtn)
|
||||
// confirmation modal should show up
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
// Event subscription support for external data tools.
|
||||
describe('ConfigVar External Data Tool Events', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestSortableProps = null
|
||||
subscriptionCallback = null
|
||||
variableIndex = 0
|
||||
notifySpy.mockClear()
|
||||
})
|
||||
|
||||
it('should append external data tool variables from event emitter', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
renderConfigVar({
|
||||
promptVariables: [],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
subscriptionCallback?.({
|
||||
type: ADD_EXTERNAL_DATA_TOOL,
|
||||
payload: {
|
||||
variable: 'api_var',
|
||||
label: 'API Var',
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {},
|
||||
icon: 'icon',
|
||||
icon_background: 'bg',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
key: 'api_var',
|
||||
name: 'API Var',
|
||||
required: true,
|
||||
type: 'api',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -3,10 +3,11 @@ import type { FC } from 'react'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useContext } from 'use-context-selector'
|
||||
@ -33,11 +34,55 @@ type ExternalDataToolParams = {
|
||||
type: string
|
||||
index: number
|
||||
name: string
|
||||
config?: Record<string, any>
|
||||
config?: PromptVariable['config']
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
}
|
||||
|
||||
const BASIC_INPUT_TYPES = new Set(['string', 'paragraph', 'select', 'number', 'checkbox'])
|
||||
|
||||
const toInputVar = (item: PromptVariable): InputVar => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
type: (item.type === 'string' ? InputVarType.textInput : item.type) as InputVarType,
|
||||
required: item.required ?? false,
|
||||
})
|
||||
|
||||
const buildPromptVariableFromInput = (payload: InputVar): PromptVariable => {
|
||||
const { variable, label, type, ...rest } = payload
|
||||
const nextType = type === InputVarType.textInput ? 'string' : type
|
||||
const nextItem: PromptVariable = {
|
||||
...rest,
|
||||
type: nextType,
|
||||
key: variable,
|
||||
name: label as string,
|
||||
}
|
||||
if (payload.type === InputVarType.textInput)
|
||||
nextItem.max_length = nextItem.max_length || DEFAULT_VALUE_MAX_LEN
|
||||
|
||||
if (payload.type !== InputVarType.select)
|
||||
delete nextItem.options
|
||||
|
||||
return nextItem
|
||||
}
|
||||
|
||||
const getDuplicateError = (list: PromptVariable[]) => {
|
||||
if (hasDuplicateStr(list.map(item => item.key))) {
|
||||
return {
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.varName',
|
||||
}
|
||||
}
|
||||
if (hasDuplicateStr(list.map(item => item.name as string))) {
|
||||
return {
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.labelName',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type IConfigVarProps = {
|
||||
promptVariables: PromptVariable[]
|
||||
readonly?: boolean
|
||||
@ -55,61 +100,31 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
const hasVar = promptVariables.length > 0
|
||||
const [currIndex, setCurrIndex] = useState<number>(-1)
|
||||
const currItem = currIndex !== -1 ? promptVariables[currIndex] : null
|
||||
const currItemToEdit: InputVar | null = (() => {
|
||||
const currItemToEdit = useMemo(() => {
|
||||
if (!currItem)
|
||||
return null
|
||||
|
||||
return {
|
||||
...currItem,
|
||||
label: currItem.name,
|
||||
variable: currItem.key,
|
||||
type: currItem.type === 'string' ? InputVarType.textInput : currItem.type,
|
||||
} as InputVar
|
||||
})()
|
||||
const updatePromptVariableItem = (payload: InputVar) => {
|
||||
return toInputVar(currItem)
|
||||
}, [currItem])
|
||||
const updatePromptVariableItem = useCallback((payload: InputVar) => {
|
||||
const newPromptVariables = produce(promptVariables, (draft) => {
|
||||
const { variable, label, type, ...rest } = payload
|
||||
draft[currIndex] = {
|
||||
...rest,
|
||||
type: type === InputVarType.textInput ? 'string' : type,
|
||||
key: variable,
|
||||
name: label as string,
|
||||
}
|
||||
|
||||
if (payload.type === InputVarType.textInput)
|
||||
draft[currIndex].max_length = draft[currIndex].max_length || DEFAULT_VALUE_MAX_LEN
|
||||
|
||||
if (payload.type !== InputVarType.select)
|
||||
delete draft[currIndex].options
|
||||
draft[currIndex] = buildPromptVariableFromInput(payload)
|
||||
})
|
||||
|
||||
const newList = newPromptVariables
|
||||
let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = ''
|
||||
let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = ''
|
||||
if (hasDuplicateStr(newList.map(item => item.key))) {
|
||||
errorMsgKey = 'varKeyError.keyAlreadyExists'
|
||||
typeName = 'variableConfig.varName'
|
||||
}
|
||||
else if (hasDuplicateStr(newList.map(item => item.name as string))) {
|
||||
errorMsgKey = 'varKeyError.keyAlreadyExists'
|
||||
typeName = 'variableConfig.labelName'
|
||||
}
|
||||
|
||||
if (errorMsgKey && typeName) {
|
||||
const duplicateError = getDuplicateError(newPromptVariables)
|
||||
if (duplicateError) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(errorMsgKey, { ns: 'appDebug', key: t(typeName, { ns: 'appDebug' }) }),
|
||||
message: t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug', key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) }) as string,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
return true
|
||||
}
|
||||
}, [currIndex, onPromptVariablesChange, promptVariables, t])
|
||||
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
|
||||
const handleOpenExternalDataToolModal = (
|
||||
const handleOpenExternalDataToolModal = useCallback((
|
||||
{ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams,
|
||||
oldPromptVariables: PromptVariable[],
|
||||
) => {
|
||||
@ -157,9 +172,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t])
|
||||
|
||||
const handleAddVar = (type: string) => {
|
||||
const handleAddVar = useCallback((type: string) => {
|
||||
const newVar = getNewVar('', type)
|
||||
const newPromptVariables = [...promptVariables, newVar]
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
@ -172,8 +187,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
index: promptVariables.length,
|
||||
}, newPromptVariables)
|
||||
}
|
||||
}
|
||||
}, [handleOpenExternalDataToolModal, onPromptVariablesChange, promptVariables])
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === ADD_EXTERNAL_DATA_TOOL) {
|
||||
const payload = v.payload
|
||||
@ -195,11 +211,11 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
|
||||
const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false)
|
||||
const [removeIndex, setRemoveIndex] = useState<number | null>(null)
|
||||
const didRemoveVar = (index: number) => {
|
||||
const didRemoveVar = useCallback((index: number) => {
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index))
|
||||
}
|
||||
}, [onPromptVariablesChange, promptVariables])
|
||||
|
||||
const handleRemoveVar = (index: number) => {
|
||||
const handleRemoveVar = useCallback((index: number) => {
|
||||
const removeVar = promptVariables[index]
|
||||
|
||||
if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) {
|
||||
@ -208,21 +224,20 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
return
|
||||
}
|
||||
didRemoveVar(index)
|
||||
}
|
||||
}, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal])
|
||||
|
||||
// const [currKey, setCurrKey] = useState<string | null>(null)
|
||||
const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false)
|
||||
|
||||
const handleConfig = ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
|
||||
const handleConfig = useCallback(({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
|
||||
// setCurrKey(key)
|
||||
setCurrIndex(index)
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number' && type !== 'checkbox') {
|
||||
if (!BASIC_INPUT_TYPES.has(type)) {
|
||||
handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables)
|
||||
return
|
||||
}
|
||||
|
||||
showEditModal()
|
||||
}
|
||||
}, [handleOpenExternalDataToolModal, promptVariables, showEditModal])
|
||||
|
||||
const promptVariablesWithIds = useMemo(() => promptVariables.map((item) => {
|
||||
return {
|
||||
|
||||
@ -65,6 +65,7 @@ const VarItem: FC<ItemProps> = ({
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div
|
||||
data-testid="var-item-delete-btn"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center text-text-tertiary hover:text-text-destructive"
|
||||
onClick={onRemove}
|
||||
onMouseOver={() => setIsDeleting(true)}
|
||||
|
||||
21
web/app/components/devtools/react-scan/loader.tsx
Normal file
21
web/app/components/devtools/react-scan/loader.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
|
||||
const ReactScan = lazy(() =>
|
||||
import('./scan').then(module => ({
|
||||
default: module.ReactScan,
|
||||
})),
|
||||
)
|
||||
|
||||
export const ReactScanLoader = () => {
|
||||
if (!IS_DEV)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ReactScan />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
21
web/app/components/devtools/tanstack/loader.tsx
Normal file
21
web/app/components/devtools/tanstack/loader.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
|
||||
const TanStackDevtoolsWrapper = lazy(() =>
|
||||
import('./devtools').then(module => ({
|
||||
default: module.TanStackDevtoolsWrapper,
|
||||
})),
|
||||
)
|
||||
|
||||
export const TanStackDevtoolsLoader = () => {
|
||||
if (!IS_DEV)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TanStackDevtoolsWrapper />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
1742
web/app/components/plugins/card/index.spec.tsx
Normal file
1742
web/app/components/plugins/card/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,525 @@
|
||||
import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../types'
|
||||
import Loaded from './loaded'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseCheckInstalled = vi.fn()
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: (params: { pluginIds: string[], enabled: boolean }) => mockUseCheckInstalled(params),
|
||||
}))
|
||||
|
||||
const mockUpdateFromGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
updateFromGitHub: (...args: unknown[]) => mockUpdateFromGitHub(...args),
|
||||
}))
|
||||
|
||||
const mockInstallPackageFromGitHub = vi.fn()
|
||||
const mockHandleRefetch = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromGitHub: () => ({ mutateAsync: mockInstallPackageFromGitHub }),
|
||||
usePluginTaskList: () => ({ handleRefetch: mockHandleRefetch }),
|
||||
}))
|
||||
|
||||
const mockCheck = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({ check: mockCheck }),
|
||||
}))
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => (
|
||||
<div data-testid="plugin-card">
|
||||
<span data-testid="card-name">{payload.name}</span>
|
||||
{titleLeft && <span data-testid="title-left">{titleLeft}</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Version component
|
||||
vi.mock('../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
toInstallVersion: string
|
||||
}) => (
|
||||
<span data-testid="version-info">
|
||||
{hasInstalled ? `Update from ${installedVersion} to ${toInstallVersion}` : `Install ${toInstallVersion}`}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
// Factory functions
|
||||
const createMockPayload = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'Test Description' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPluginPayload = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-pkg',
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Test' },
|
||||
brief: { 'en-US': 'Brief' },
|
||||
description: { 'en-US': 'Description' },
|
||||
introduction: 'Intro',
|
||||
repository: '',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createUpdatePayload = (): UpdateFromGitHubPayload => ({
|
||||
originalPackageInfo: {
|
||||
id: 'original-id',
|
||||
repo: 'owner/repo',
|
||||
version: 'v0.9.0',
|
||||
package: 'plugin.zip',
|
||||
releases: [],
|
||||
},
|
||||
})
|
||||
|
||||
describe('Loaded', () => {
|
||||
const defaultProps = {
|
||||
updatePayload: undefined,
|
||||
uniqueIdentifier: 'test-unique-id',
|
||||
payload: createMockPayload() as PluginDeclaration | Plugin,
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onBack: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
onInstalled: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: false,
|
||||
})
|
||||
mockUpdateFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render ready to install message', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render back button when not installing', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render install button', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show version info in card title', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-info')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Tests
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should display plugin name from payload', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should pass correct version to Version component', () => {
|
||||
render(<Loaded {...defaultProps} payload={createMockPayload({ version: '2.0.0' })} />)
|
||||
|
||||
expect(screen.getByTestId('version-info')).toHaveTextContent('Install 2.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button State Tests
|
||||
// ================================
|
||||
describe('Button State', () => {
|
||||
it('should disable install button while loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable install button when not loading', () => {
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const onBack = vi.fn()
|
||||
render(<Loaded {...defaultProps} onBack={onBack} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onStartToInstall when install starts', async () => {
|
||||
const onStartToInstall = vi.fn()
|
||||
render(<Loaded {...defaultProps} onStartToInstall={onStartToInstall} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installation Flow Tests
|
||||
// ================================
|
||||
describe('Installation Flows', () => {
|
||||
it('should call installPackageFromGitHub for fresh install', async () => {
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromGitHub).toHaveBeenCalledWith({
|
||||
repoUrl: 'owner/repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
uniqueIdentifier: 'test-unique-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateFromGitHub when updatePayload is provided', async () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
render(<Loaded {...defaultProps} updatePayload={updatePayload} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFromGitHub).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
'original-id',
|
||||
'test-unique-id',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateFromGitHub when plugin is already installed', async () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-plugin-id': {
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'installed-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFromGitHub).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
'installed-uid',
|
||||
'test-unique-id',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled when installation completes immediately', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check task status when not immediately installed', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefetch).toHaveBeenCalled()
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
pluginUniqueIdentifier: 'test-unique-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled with true when task succeeds', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Handling Tests
|
||||
// ================================
|
||||
describe('Error Handling', () => {
|
||||
it('should call onFailed when task fails', async () => {
|
||||
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Installation failed' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Loaded {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Installation failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed with string error', async () => {
|
||||
mockInstallPackageFromGitHub.mockRejectedValue('String error message')
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Loaded {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('String error message')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed without message for non-string errors', async () => {
|
||||
mockInstallPackageFromGitHub.mockRejectedValue(new Error('Error object'))
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Loaded {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Auto-install Effect Tests
|
||||
// ================================
|
||||
describe('Auto-install Effect', () => {
|
||||
it('should call onInstalled when already installed with same identifier', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-plugin-id': {
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'test-unique-id',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />)
|
||||
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onInstalled when identifiers differ', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-plugin-id': {
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'different-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />)
|
||||
|
||||
expect(onInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installing State Tests
|
||||
// ================================
|
||||
describe('Installing State', () => {
|
||||
it('should hide back button while installing', async () => {
|
||||
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
|
||||
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}))
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveInstall!({ all_installed: true, task_id: 'task-1' })
|
||||
})
|
||||
|
||||
it('should show installing text while installing', async () => {
|
||||
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
|
||||
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}))
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveInstall!({ all_installed: true, task_id: 'task-1' })
|
||||
})
|
||||
|
||||
it('should not trigger install twice when already installing', async () => {
|
||||
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
|
||||
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}))
|
||||
|
||||
render(<Loaded {...defaultProps} />)
|
||||
|
||||
const installButton = screen.getByRole('button', { name: /plugin.installModal.install/i })
|
||||
|
||||
// Click twice
|
||||
fireEvent.click(installButton)
|
||||
fireEvent.click(installButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromGitHub).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
resolveInstall!({ all_installed: true, task_id: 'task-1' })
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing onStartToInstall callback', async () => {
|
||||
render(<Loaded {...defaultProps} onStartToInstall={undefined} />)
|
||||
|
||||
// Should not throw when callback is undefined
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
|
||||
}).not.toThrow()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromGitHub).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle plugin without plugin_id', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Loaded {...defaultProps} payload={createMockPayload()} />)
|
||||
|
||||
expect(mockUseCheckInstalled).toHaveBeenCalledWith({
|
||||
pluginIds: [undefined],
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve state after component update', () => {
|
||||
const { rerender } = render(<Loaded {...defaultProps} />)
|
||||
|
||||
rerender(<Loaded {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -16,7 +16,7 @@ import Version from '../../base/version'
|
||||
import { parseGitHubUrl, pluginManifestToCardPluginProps } from '../../utils'
|
||||
|
||||
type LoadedProps = {
|
||||
updatePayload: UpdateFromGitHubPayload
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
uniqueIdentifier: string
|
||||
payload: PluginDeclaration | Plugin
|
||||
repoUrl: string
|
||||
|
||||
@ -0,0 +1,877 @@
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
import SelectPackage from './selectPackage'
|
||||
|
||||
// Mock the useGitHubUpload hook
|
||||
const mockHandleUpload = vi.fn()
|
||||
vi.mock('../../hooks', () => ({
|
||||
useGitHubUpload: () => ({ handleUpload: mockHandleUpload }),
|
||||
}))
|
||||
|
||||
// Factory functions
|
||||
const createMockManifest = (): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'Test Description' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
})
|
||||
|
||||
const createVersions = (): Item[] => [
|
||||
{ value: 'v1.0.0', name: 'v1.0.0' },
|
||||
{ value: 'v0.9.0', name: 'v0.9.0' },
|
||||
]
|
||||
|
||||
const createPackages = (): Item[] => [
|
||||
{ value: 'plugin.zip', name: 'plugin.zip' },
|
||||
{ value: 'plugin.tar.gz', name: 'plugin.tar.gz' },
|
||||
]
|
||||
|
||||
const createUpdatePayload = (): UpdateFromGitHubPayload => ({
|
||||
originalPackageInfo: {
|
||||
id: 'original-id',
|
||||
repo: 'owner/repo',
|
||||
version: 'v0.9.0',
|
||||
package: 'plugin.zip',
|
||||
releases: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Test props type - updatePayload is optional for testing
|
||||
type TestProps = {
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
repoUrl?: string
|
||||
selectedVersion?: string
|
||||
versions?: Item[]
|
||||
onSelectVersion?: (item: Item) => void
|
||||
selectedPackage?: string
|
||||
packages?: Item[]
|
||||
onSelectPackage?: (item: Item) => void
|
||||
onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void
|
||||
onFailed?: (errorMsg: string) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
describe('SelectPackage', () => {
|
||||
const createDefaultProps = () => ({
|
||||
updatePayload: undefined as UpdateFromGitHubPayload | undefined,
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: '',
|
||||
versions: createVersions(),
|
||||
onSelectVersion: vi.fn() as (item: Item) => void,
|
||||
selectedPackage: '',
|
||||
packages: createPackages(),
|
||||
onSelectPackage: vi.fn() as (item: Item) => void,
|
||||
onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void,
|
||||
onFailed: vi.fn() as (errorMsg: string) => void,
|
||||
onBack: vi.fn() as () => void,
|
||||
})
|
||||
|
||||
// Helper function to render with proper type handling
|
||||
const renderSelectPackage = (overrides: TestProps = {}) => {
|
||||
const props = { ...createDefaultProps(), ...overrides }
|
||||
// Cast to any to bypass strict type checking since component accepts optional updatePayload
|
||||
return render(<SelectPackage {...(props as Parameters<typeof SelectPackage>[0])} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHandleUpload.mockReset()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render version label', () => {
|
||||
renderSelectPackage()
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render package label', () => {
|
||||
renderSelectPackage()
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render back button when not in edit mode', () => {
|
||||
renderSelectPackage({ updatePayload: undefined })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render back button when in edit mode', () => {
|
||||
renderSelectPackage({ updatePayload: createUpdatePayload() })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render next button', () => {
|
||||
renderSelectPackage()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Tests
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should pass selectedVersion to PortalSelect', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// PortalSelect should display the selected version
|
||||
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass selectedPackage to PortalSelect', () => {
|
||||
renderSelectPackage({ selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByText('plugin.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show installed version badge when updatePayload version differs', () => {
|
||||
renderSelectPackage({
|
||||
updatePayload: createUpdatePayload(),
|
||||
selectedVersion: 'v1.0.0',
|
||||
})
|
||||
|
||||
expect(screen.getByText(/v0\.9\.0\s*->\s*v1\.0\.0/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button State Tests
|
||||
// ================================
|
||||
describe('Button State', () => {
|
||||
it('should disable next button when no version selected', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when version selected but no package', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable next button when both version and package selected', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const onBack = vi.fn()
|
||||
renderSelectPackage({ onBack })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleUploadPackage when next button is clicked', async () => {
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not invoke upload when next button is disabled', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
expect(mockHandleUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Upload Handling Tests
|
||||
// ================================
|
||||
describe('Upload Handling', () => {
|
||||
it('should call onUploaded with correct data on successful upload', async () => {
|
||||
const mockManifest = createMockManifest()
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'test-uid', manifest: mockManifest })
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'test-uid',
|
||||
manifest: mockManifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed with response message on upload error', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: { message: 'API Error' } })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('API Error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed with default message when no response message', async () => {
|
||||
mockHandleUpload.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call upload twice when already uploading', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'plugin.installModal.next' })
|
||||
|
||||
// Click twice rapidly - this tests the isUploading guard at line 49-50
|
||||
// The first click starts the upload, the second should be ignored
|
||||
fireEvent.click(nextButton)
|
||||
fireEvent.click(nextButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Resolve the upload
|
||||
resolveUpload!()
|
||||
})
|
||||
|
||||
it('should disable back button while uploading', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
|
||||
it('should strip github.com prefix from repoUrl', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/myorg/myrepo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'myorg/myrepo',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty versions array', () => {
|
||||
renderSelectPackage({ versions: [] })
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty packages array', () => {
|
||||
renderSelectPackage({ packages: [] })
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle updatePayload with installed version', () => {
|
||||
renderSelectPackage({ updatePayload: createUpdatePayload() })
|
||||
|
||||
// Should not show back button in edit mode
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-enable buttons after upload completes', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-enable buttons after upload fails', async () => {
|
||||
mockHandleUpload.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// PortalSelect Readonly State Tests
|
||||
// ================================
|
||||
describe('PortalSelect Readonly State', () => {
|
||||
it('should make package select readonly when no version selected', () => {
|
||||
renderSelectPackage({ selectedVersion: '' })
|
||||
|
||||
// When no version is selected, package select should be readonly
|
||||
// This is tested by verifying the component renders correctly
|
||||
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
|
||||
expect(trigger).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should make package select active when version is selected', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// When version is selected, package select should be active
|
||||
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
|
||||
expect(trigger).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// installedValue Props Tests
|
||||
// ================================
|
||||
describe('installedValue Props', () => {
|
||||
it('should pass installedValue when updatePayload is provided', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
renderSelectPackage({ updatePayload })
|
||||
|
||||
// The installed version should be passed to PortalSelect
|
||||
// updatePayload.originalPackageInfo.version = 'v0.9.0'
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not pass installedValue when updatePayload is undefined', () => {
|
||||
renderSelectPackage({ updatePayload: undefined })
|
||||
|
||||
// No installed version indicator
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle updatePayload with different version value', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
updatePayload.originalPackageInfo.version = 'v2.0.0'
|
||||
renderSelectPackage({ updatePayload })
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show installed badge in version list', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
renderSelectPackage({ updatePayload, selectedVersion: '' })
|
||||
|
||||
fireEvent.click(screen.getByText('plugin.installFromGitHub.selectVersionPlaceholder'))
|
||||
|
||||
expect(screen.getByText('INSTALLED')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Next Button Disabled State Combinations
|
||||
// ================================
|
||||
describe('Next Button Disabled State Combinations', () => {
|
||||
it('should disable next button when only version is missing', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when only package is missing', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when both are missing', () => {
|
||||
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when uploading even with valid selections', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// RepoUrl Format Handling Tests
|
||||
// ================================
|
||||
describe('RepoUrl Format Handling', () => {
|
||||
it('should handle repoUrl without trailing slash', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle repoUrl with different org/repo combinations', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/my-organization/my-plugin-repo',
|
||||
selectedVersion: 'v2.0.0',
|
||||
selectedPackage: 'build.tar.gz',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'my-organization/my-plugin-repo',
|
||||
'v2.0.0',
|
||||
'build.tar.gz',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass through repoUrl without github prefix', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'plain-org/plain-repo',
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'plain-org/plain-repo',
|
||||
'v1.0.0',
|
||||
'plugin.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// isEdit Mode Comprehensive Tests
|
||||
// ================================
|
||||
describe('isEdit Mode Comprehensive', () => {
|
||||
it('should set isEdit to true when updatePayload is truthy', () => {
|
||||
const updatePayload = createUpdatePayload()
|
||||
renderSelectPackage({ updatePayload })
|
||||
|
||||
// Back button should not be rendered in edit mode
|
||||
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set isEdit to false when updatePayload is undefined', () => {
|
||||
renderSelectPackage({ updatePayload: undefined })
|
||||
|
||||
// Back button should be rendered when not in edit mode
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow upload in edit mode without back button', async () => {
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
updatePayload: createUpdatePayload(),
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Response Handling Tests
|
||||
// ================================
|
||||
describe('Error Response Handling', () => {
|
||||
it('should handle error with response.message property', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: { message: 'Custom API Error' } })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Custom API Error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error with empty response object', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: {} })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error without response property', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ code: 'NETWORK_ERROR' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error with response but no message', async () => {
|
||||
mockHandleUpload.mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle string error', async () => {
|
||||
mockHandleUpload.mockRejectedValue('String error message')
|
||||
|
||||
const onFailed = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onFailed,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Props Tests
|
||||
// ================================
|
||||
describe('Callback Props', () => {
|
||||
it('should pass onSelectVersion to PortalSelect', () => {
|
||||
const onSelectVersion = vi.fn()
|
||||
renderSelectPackage({ onSelectVersion })
|
||||
|
||||
// The callback is passed to PortalSelect, which is a base component
|
||||
// We verify it's rendered correctly
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass onSelectPackage to PortalSelect', () => {
|
||||
const onSelectPackage = vi.fn()
|
||||
renderSelectPackage({ onSelectPackage })
|
||||
|
||||
// The callback is passed to PortalSelect, which is a base component
|
||||
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Upload State Management Tests
|
||||
// ================================
|
||||
describe('Upload State Management', () => {
|
||||
it('should set isUploading to true when upload starts', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
// Both buttons should be disabled during upload
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
|
||||
it('should set isUploading to false after successful upload', async () => {
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
|
||||
})
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isUploading to false after failed upload', async () => {
|
||||
mockHandleUpload.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow back button click while uploading', async () => {
|
||||
let resolveUpload: (value?: unknown) => void
|
||||
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}))
|
||||
|
||||
const onBack = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onBack,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
// Try to click back button while disabled
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
|
||||
|
||||
// onBack should not be called
|
||||
expect(onBack).not.toHaveBeenCalled()
|
||||
|
||||
resolveUpload!()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// handleUpload Callback Tests
|
||||
// ================================
|
||||
describe('handleUpload Callback', () => {
|
||||
it('should invoke onSuccess callback with correct data structure', async () => {
|
||||
const mockManifest = createMockManifest()
|
||||
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
|
||||
onSuccess({
|
||||
unique_identifier: 'test-unique-identifier',
|
||||
manifest: mockManifest,
|
||||
})
|
||||
})
|
||||
|
||||
const onUploaded = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
selectedPackage: 'plugin.zip',
|
||||
onUploaded,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
manifest: mockManifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct repo, version, and package to handleUpload', async () => {
|
||||
mockHandleUpload.mockResolvedValue({})
|
||||
|
||||
renderSelectPackage({
|
||||
repoUrl: 'https://github.com/test-org/test-repo',
|
||||
selectedVersion: 'v3.0.0',
|
||||
selectedPackage: 'release.zip',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpload).toHaveBeenCalledWith(
|
||||
'test-org/test-repo',
|
||||
'v3.0.0',
|
||||
'release.zip',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,180 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SetURL from './setURL'
|
||||
|
||||
describe('SetURL', () => {
|
||||
const defaultProps = {
|
||||
repoUrl: '',
|
||||
onChange: vi.fn(),
|
||||
onNext: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render label with GitHub repo text', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installFromGitHub.gitHubRepo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input field with correct attributes', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('type', 'url')
|
||||
expect(input).toHaveAttribute('id', 'repoUrl')
|
||||
expect(input).toHaveAttribute('name', 'repoUrl')
|
||||
expect(input).toHaveAttribute('placeholder', 'Please enter GitHub repo URL')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render next button', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should associate label with input field', () => {
|
||||
render(<SetURL {...defaultProps} />)
|
||||
|
||||
const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Tests
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should display repoUrl value in input', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('https://github.com/test/repo')
|
||||
})
|
||||
|
||||
it('should display empty string when repoUrl is empty', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="" />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when input value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SetURL {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith('https://github.com/owner/repo')
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<SetURL {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.cancel' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onNext when next button is clicked', () => {
|
||||
const onNext = vi.fn()
|
||||
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" onNext={onNext} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
expect(onNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button State Tests
|
||||
// ================================
|
||||
describe('Button State', () => {
|
||||
it('should disable next button when repoUrl is empty', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button when repoUrl is only whitespace', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl=" " />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable next button when repoUrl has content', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable cancel button regardless of repoUrl', () => {
|
||||
render(<SetURL {...defaultProps} repoUrl="" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle URL with special characters', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SetURL {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://github.com/test-org/repo_name-123' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('https://github.com/test-org/repo_name-123')
|
||||
})
|
||||
|
||||
it('should handle very long URLs', () => {
|
||||
const longUrl = `https://github.com/${'a'.repeat(100)}/${'b'.repeat(100)}`
|
||||
render(<SetURL {...defaultProps} repoUrl={longUrl} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue(longUrl)
|
||||
})
|
||||
|
||||
it('should handle onChange with empty string', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SetURL {...defaultProps} repoUrl="some-value" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should preserve callback references on rerender', () => {
|
||||
const onNext = vi.fn()
|
||||
const { rerender } = render(<SetURL {...defaultProps} repoUrl="https://github.com/a/b" onNext={onNext} />)
|
||||
|
||||
rerender(<SetURL {...defaultProps} repoUrl="https://github.com/a/b" onNext={onNext} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
|
||||
|
||||
expect(onNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,471 @@
|
||||
import type { PluginDeclaration } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../types'
|
||||
import ReadyToInstall from './ready-to-install'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: mockRefreshPluginList,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Install component
|
||||
let _installOnInstalled: ((notRefresh?: boolean) => void) | null = null
|
||||
let _installOnFailed: ((message?: string) => void) | null = null
|
||||
let _installOnCancel: (() => void) | null = null
|
||||
let _installOnStartToInstall: (() => void) | null = null
|
||||
|
||||
vi.mock('./steps/install', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
onCancel,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
}: {
|
||||
uniqueIdentifier: string
|
||||
payload: PluginDeclaration
|
||||
onCancel: () => void
|
||||
onStartToInstall?: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
}) => {
|
||||
_installOnInstalled = onInstalled
|
||||
_installOnFailed = onFailed
|
||||
_installOnCancel = onCancel
|
||||
_installOnStartToInstall = onStartToInstall ?? null
|
||||
return (
|
||||
<div data-testid="install-step">
|
||||
<span data-testid="install-uid">{uniqueIdentifier}</span>
|
||||
<span data-testid="install-payload-name">{payload.name}</span>
|
||||
<button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="install-start-btn" onClick={() => onStartToInstall?.()}>
|
||||
Start Install
|
||||
</button>
|
||||
<button data-testid="install-installed-btn" onClick={() => onInstalled()}>
|
||||
Installed
|
||||
</button>
|
||||
<button data-testid="install-installed-no-refresh-btn" onClick={() => onInstalled(true)}>
|
||||
Installed (No Refresh)
|
||||
</button>
|
||||
<button data-testid="install-failed-btn" onClick={() => onFailed()}>
|
||||
Failed
|
||||
</button>
|
||||
<button data-testid="install-failed-msg-btn" onClick={() => onFailed('Error message')}>
|
||||
Failed with Message
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Installed component
|
||||
vi.mock('../base/installed', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isFailed,
|
||||
errMsg,
|
||||
onCancel,
|
||||
}: {
|
||||
payload: PluginDeclaration | null
|
||||
isFailed: boolean
|
||||
errMsg: string | null
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="installed-step">
|
||||
<span data-testid="installed-payload-name">{payload?.name || 'null'}</span>
|
||||
<span data-testid="installed-is-failed">{isFailed ? 'true' : 'false'}</span>
|
||||
<span data-testid="installed-err-msg">{errMsg || 'null'}</span>
|
||||
<button data-testid="installed-cancel-btn" onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ReadyToInstall', () => {
|
||||
const defaultProps = {
|
||||
step: InstallStep.readyToInstall,
|
||||
onStepChange: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
setIsInstalling: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
manifest: createMockManifest(),
|
||||
errorMsg: null as string | null,
|
||||
onError: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_installOnInstalled = null
|
||||
_installOnFailed = null
|
||||
_installOnCancel = null
|
||||
_installOnStartToInstall = null
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render Install component when step is readyToInstall', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Installed component when step is uploadFailed', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.uploadFailed} />)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Installed component when step is installed', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Installed component when step is installFailed', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} />)
|
||||
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Passing Tests
|
||||
// ================================
|
||||
describe('Props Passing', () => {
|
||||
it('should pass uniqueIdentifier to Install component', () => {
|
||||
render(<ReadyToInstall {...defaultProps} uniqueIdentifier="custom-uid" />)
|
||||
|
||||
expect(screen.getByTestId('install-uid')).toHaveTextContent('custom-uid')
|
||||
})
|
||||
|
||||
it('should pass manifest to Install component', () => {
|
||||
const manifest = createMockManifest({ name: 'Custom Plugin' })
|
||||
render(<ReadyToInstall {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-payload-name')).toHaveTextContent('Custom Plugin')
|
||||
})
|
||||
|
||||
it('should pass manifest to Installed component', () => {
|
||||
const manifest = createMockManifest({ name: 'Installed Plugin' })
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('Installed Plugin')
|
||||
})
|
||||
|
||||
it('should pass errorMsg to Installed component', () => {
|
||||
render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
step={InstallStep.installFailed}
|
||||
errorMsg="Some error"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('Some error')
|
||||
})
|
||||
|
||||
it('should pass isFailed=true for uploadFailed step', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.uploadFailed} />)
|
||||
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass isFailed=true for installFailed step', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} />)
|
||||
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass isFailed=false for installed step', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />)
|
||||
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// handleInstalled Callback Tests
|
||||
// ================================
|
||||
describe('handleInstalled Callback', () => {
|
||||
it('should call onStepChange with installed when handleInstalled is triggered', () => {
|
||||
const onStepChange = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onStepChange={onStepChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
})
|
||||
|
||||
it('should call refreshPluginList when handleInstalled is triggered without notRefresh', () => {
|
||||
const manifest = createMockManifest()
|
||||
render(<ReadyToInstall {...defaultProps} manifest={manifest} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(mockRefreshPluginList).toHaveBeenCalledWith(manifest)
|
||||
})
|
||||
|
||||
it('should not call refreshPluginList when handleInstalled is triggered with notRefresh=true', () => {
|
||||
render(<ReadyToInstall {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-no-refresh-btn'))
|
||||
|
||||
expect(mockRefreshPluginList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setIsInstalling(false) when handleInstalled is triggered', () => {
|
||||
const setIsInstalling = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} setIsInstalling={setIsInstalling} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// handleFailed Callback Tests
|
||||
// ================================
|
||||
describe('handleFailed Callback', () => {
|
||||
it('should call onStepChange with installFailed when handleFailed is triggered', () => {
|
||||
const onStepChange = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onStepChange={onStepChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
|
||||
})
|
||||
|
||||
it('should call setIsInstalling(false) when handleFailed is triggered', () => {
|
||||
const setIsInstalling = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} setIsInstalling={setIsInstalling} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call onError when handleFailed is triggered with error message', () => {
|
||||
const onError = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onError={onError} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-msg-btn'))
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Error message')
|
||||
})
|
||||
|
||||
it('should not call onError when handleFailed is triggered without error message', () => {
|
||||
const onError = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onError={onError} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// onClose Callback Tests
|
||||
// ================================
|
||||
describe('onClose Callback', () => {
|
||||
it('should call onClose when cancel is clicked in Install component', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-cancel-btn'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel is clicked in Installed component', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('installed-cancel-btn'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// onStartToInstall Callback Tests
|
||||
// ================================
|
||||
describe('onStartToInstall Callback', () => {
|
||||
it('should pass onStartToInstall to Install component', () => {
|
||||
const onStartToInstall = vi.fn()
|
||||
render(<ReadyToInstall {...defaultProps} onStartToInstall={onStartToInstall} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-start-btn'))
|
||||
|
||||
expect(onStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Step Transitions Tests
|
||||
// ================================
|
||||
describe('Step Transitions', () => {
|
||||
it('should handle transition from readyToInstall to installed', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />,
|
||||
)
|
||||
|
||||
// Initially shows Install component
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
// Simulate successful installation
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
|
||||
// Rerender with new step
|
||||
rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onStepChange={onStepChange} />)
|
||||
|
||||
// Now shows Installed component
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle transition from readyToInstall to installFailed', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />,
|
||||
)
|
||||
|
||||
// Initially shows Install component
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
// Simulate failed installation
|
||||
fireEvent.click(screen.getByTestId('install-failed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
|
||||
|
||||
// Rerender with new step
|
||||
rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} onStepChange={onStepChange} />)
|
||||
|
||||
// Now shows Installed component with failed state
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null manifest', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} manifest={null} />)
|
||||
|
||||
expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('null')
|
||||
})
|
||||
|
||||
it('should handle null errorMsg', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg={null} />)
|
||||
|
||||
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null')
|
||||
})
|
||||
|
||||
it('should handle empty string errorMsg', () => {
|
||||
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg="" />)
|
||||
|
||||
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Stability Tests
|
||||
// ================================
|
||||
describe('Callback Stability', () => {
|
||||
it('should maintain stable handleInstalled callback across re-renders', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const setIsInstalling = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Callback should still work
|
||||
fireEvent.click(screen.getByTestId('install-installed-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should maintain stable handleFailed callback across re-renders', () => {
|
||||
const onStepChange = vi.fn()
|
||||
const setIsInstalling = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<ReadyToInstall
|
||||
{...defaultProps}
|
||||
onStepChange={onStepChange}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Callback should still work
|
||||
fireEvent.click(screen.getByTestId('install-failed-msg-btn'))
|
||||
|
||||
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
|
||||
expect(setIsInstalling).toHaveBeenCalledWith(false)
|
||||
expect(onError).toHaveBeenCalledWith('Error message')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,626 @@
|
||||
import type { PluginDeclaration } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../types'
|
||||
import Install from './install'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0', minimum_dify_version: '0.8.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock external dependencies
|
||||
const mockUseCheckInstalled = vi.fn()
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => mockUseCheckInstalled(),
|
||||
}))
|
||||
|
||||
const mockInstallPackageFromLocal = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromLocal: () => ({
|
||||
mutateAsync: mockInstallPackageFromLocal,
|
||||
}),
|
||||
usePluginTaskList: () => ({
|
||||
handleRefetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUninstallPlugin = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: (...args: unknown[]) => mockUninstallPlugin(...args),
|
||||
}))
|
||||
|
||||
const mockCheck = vi.fn()
|
||||
const mockStop = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheck,
|
||||
stop: mockStop,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
langGeniusVersionInfo: mockLangGeniusVersionInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
// Handle interpolation params (excluding ns)
|
||||
const { ns: _ns, ...params } = options || {}
|
||||
if (Object.keys(params).length > 0) {
|
||||
return `${fullKey}:${JSON.stringify(params)}`
|
||||
}
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
|
||||
<span data-testid="trans">
|
||||
{i18nKey}
|
||||
{components?.trustSource}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: {
|
||||
payload: Record<string, unknown>
|
||||
titleLeft?: React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="card">
|
||||
<span data-testid="card-name">{payload?.name as string}</span>
|
||||
<div data-testid="card-title-left">{titleLeft}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
toInstallVersion: string
|
||||
}) => (
|
||||
<div data-testid="version">
|
||||
<span data-testid="version-has-installed">{hasInstalled ? 'true' : 'false'}</span>
|
||||
<span data-testid="version-installed">{installedVersion || 'null'}</span>
|
||||
<span data-testid="version-to-install">{toInstallVersion}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({
|
||||
name: manifest.name,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Install', () => {
|
||||
const defaultProps = {
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
payload: createMockManifest(),
|
||||
onCancel: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
onInstalled: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockInstallPackageFromLocal.mockReset()
|
||||
mockUninstallPlugin.mockReset()
|
||||
mockCheck.mockReset()
|
||||
mockStop.mockReset()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render ready to install message', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trust source message', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('trans')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render install button', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show version component when not loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show version component when loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('version')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Version Display Tests
|
||||
// ================================
|
||||
describe('Version Display', () => {
|
||||
it('should display toInstallVersion from payload', () => {
|
||||
const payload = createMockManifest({ version: '2.0.0' })
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('version-to-install')).toHaveTextContent('2.0.0')
|
||||
})
|
||||
|
||||
it('should display hasInstalled=false when not installed', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should display hasInstalled=true when already installed', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '0.9.0',
|
||||
installedId: 'installed-id',
|
||||
uniqueIdentifier: 'old-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-has-installed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('version-installed')).toHaveTextContent('0.9.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Install Button State Tests
|
||||
// ================================
|
||||
describe('Install Button State', () => {
|
||||
it('should disable install button when loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable install button when not loading', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Cancel Button Tests
|
||||
// ================================
|
||||
describe('Cancel Button', () => {
|
||||
it('should call onCancel and stop when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<Install {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(mockStop).toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should hide cancel button when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installation Flow Tests
|
||||
// ================================
|
||||
describe('Installation Flow', () => {
|
||||
it('should call onStartToInstall when install button is clicked', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
const onStartToInstall = vi.fn()
|
||||
render(<Install {...defaultProps} onStartToInstall={onStartToInstall} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled when all_installed is true', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check task status when all_installed is false', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test-unique-identifier',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed when task status is failed', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Task failed error' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Install {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Task failed error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should uninstall existing plugin before installing new version', async () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '0.9.0',
|
||||
installedId: 'installed-id-to-uninstall',
|
||||
uniqueIdentifier: 'old-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mockUninstallPlugin.mockResolvedValue({})
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('installed-id-to-uninstall')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromLocal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Handling Tests
|
||||
// ================================
|
||||
describe('Error Handling', () => {
|
||||
it('should call onFailed with error string', async () => {
|
||||
mockInstallPackageFromLocal.mockRejectedValue('Installation error string')
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Install {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith('Installation error string')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed without message when error is not string', async () => {
|
||||
mockInstallPackageFromLocal.mockRejectedValue({ code: 'ERROR' })
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Install {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Auto Install Behavior Tests
|
||||
// ================================
|
||||
describe('Auto Install Behavior', () => {
|
||||
it('should call onInstalled when already installed with same uniqueIdentifier', async () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '1.0.0',
|
||||
installedId: 'installed-id',
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-call onInstalled when uniqueIdentifier differs', () => {
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {
|
||||
'test-author/Test Plugin': {
|
||||
installedVersion: '1.0.0',
|
||||
installedId: 'installed-id',
|
||||
uniqueIdentifier: 'different-uid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(<Install {...defaultProps} onInstalled={onInstalled} />)
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dify Version Compatibility Tests
|
||||
// ================================
|
||||
describe('Dify Version Compatibility', () => {
|
||||
it('should not show warning when dify version is compatible', () => {
|
||||
mockLangGeniusVersionInfo.current_version = '1.0.0'
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when dify version is incompatible', () => {
|
||||
mockLangGeniusVersionInfo.current_version = '1.0.0'
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be compatible when minimum_dify_version is undefined', () => {
|
||||
mockLangGeniusVersionInfo.current_version = '1.0.0'
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be compatible when current_version is empty', () => {
|
||||
mockLangGeniusVersionInfo.current_version = ''
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
// When current_version is empty, should be compatible (no warning)
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be compatible when current_version is undefined', () => {
|
||||
mockLangGeniusVersionInfo.current_version = undefined as unknown as string
|
||||
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
|
||||
|
||||
render(<Install {...defaultProps} payload={payload} />)
|
||||
|
||||
// When current_version is undefined, should be compatible (no warning)
|
||||
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installing State Tests
|
||||
// ================================
|
||||
describe('Installing State', () => {
|
||||
it('should show installing text when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable install button when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /plugin.installModal.installing/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading spinner when installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector('.animate-spin-slow')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger install twice when already installing', async () => {
|
||||
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' })
|
||||
|
||||
// Click install
|
||||
fireEvent.click(installButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Try to click again (button should be disabled but let's verify the guard works)
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.installing/ }))
|
||||
|
||||
// Should still only be called once due to isInstalling guard
|
||||
expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Props Tests
|
||||
// ================================
|
||||
describe('Callback Props', () => {
|
||||
it('should work without onStartToInstall callback', async () => {
|
||||
mockInstallPackageFromLocal.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
|
||||
const onInstalled = vi.fn()
|
||||
render(
|
||||
<Install
|
||||
{...defaultProps}
|
||||
onStartToInstall={undefined}
|
||||
onInstalled={onInstalled}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,356 @@
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
import Uploading from './uploading'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'Test Plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
||||
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockDependencies = (): Dependency[] => [
|
||||
{
|
||||
type: 'package',
|
||||
value: {
|
||||
unique_identifier: 'dep-1',
|
||||
manifest: createMockManifest({ name: 'Dep Plugin 1' }),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const createMockFile = (name: string = 'test-plugin.difypkg'): File => {
|
||||
return new File(['test content'], name, { type: 'application/octet-stream' })
|
||||
}
|
||||
|
||||
// Mock external dependencies
|
||||
const mockUploadFile = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadFile: (...args: unknown[]) => mockUploadFile(...args),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
// Handle interpolation params (excluding ns)
|
||||
const { ns: _ns, ...params } = options || {}
|
||||
if (Object.keys(params).length > 0) {
|
||||
return `${fullKey}:${JSON.stringify(params)}`
|
||||
}
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, isLoading, loadingFileName }: {
|
||||
payload: { name: string }
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
}) => (
|
||||
<div data-testid="card">
|
||||
<span data-testid="card-name">{payload?.name}</span>
|
||||
<span data-testid="card-is-loading">{isLoading ? 'true' : 'false'}</span>
|
||||
<span data-testid="card-loading-filename">{loadingFileName || 'null'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Uploading', () => {
|
||||
const defaultProps = {
|
||||
isBundle: false,
|
||||
file: createMockFile(),
|
||||
onCancel: vi.fn(),
|
||||
onPackageUploaded: vi.fn(),
|
||||
onBundleUploaded: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUploadFile.mockReset()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render uploading message with file name', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/plugin.installModal.uploadingPackage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading spinner', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
// The spinner has animate-spin-slow class
|
||||
const spinner = document.querySelector('.animate-spin-slow')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render card with loading state', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-is-loading')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should render card with file name', () => {
|
||||
const file = createMockFile('my-plugin.difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('my-plugin.difypkg')
|
||||
expect(screen.getByTestId('card-loading-filename')).toHaveTextContent('my-plugin.difypkg')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled install button', () => {
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' })
|
||||
expect(installButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Upload Behavior Tests
|
||||
// ================================
|
||||
describe('Upload Behavior', () => {
|
||||
it('should call uploadFile on mount', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call uploadFile with isBundle=true for bundle files', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} isBundle />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed when upload fails with error message', async () => {
|
||||
const errorMessage = 'Upload failed: file too large'
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: { message: errorMessage },
|
||||
})
|
||||
|
||||
const onFailed = vi.fn()
|
||||
render(<Uploading {...defaultProps} onFailed={onFailed} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFailed).toHaveBeenCalledWith(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: The uploadFile API has an unconventional contract where it always rejects.
|
||||
// Success vs failure is determined by whether response.message exists:
|
||||
// - If response.message exists → treated as failure (calls onFailed)
|
||||
// - If response.message is absent → treated as success (calls onPackageUploaded/onBundleUploaded)
|
||||
// This explains why we use mockRejectedValue for "success" scenarios below.
|
||||
|
||||
it('should call onPackageUploaded when upload rejects without error message (success case)', async () => {
|
||||
const mockResult = {
|
||||
unique_identifier: 'test-uid',
|
||||
manifest: createMockManifest(),
|
||||
}
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: mockResult,
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(
|
||||
<Uploading
|
||||
{...defaultProps}
|
||||
isBundle={false}
|
||||
onPackageUploaded={onPackageUploaded}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: mockResult.unique_identifier,
|
||||
manifest: mockResult.manifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onBundleUploaded when upload rejects without error message (success case)', async () => {
|
||||
const mockDependencies = createMockDependencies()
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: mockDependencies,
|
||||
})
|
||||
|
||||
const onBundleUploaded = vi.fn()
|
||||
render(
|
||||
<Uploading
|
||||
{...defaultProps}
|
||||
isBundle
|
||||
onBundleUploaded={onBundleUploaded}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Cancel Button Tests
|
||||
// ================================
|
||||
describe('Cancel Button', () => {
|
||||
it('should call onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
render(<Uploading {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// File Name Display Tests
|
||||
// ================================
|
||||
describe('File Name Display', () => {
|
||||
it('should display correct file name for package file', () => {
|
||||
const file = createMockFile('custom-plugin.difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('custom-plugin.difypkg')
|
||||
})
|
||||
|
||||
it('should display correct file name for bundle file', () => {
|
||||
const file = createMockFile('custom-bundle.difybndl')
|
||||
render(<Uploading {...defaultProps} file={file} isBundle />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('custom-bundle.difybndl')
|
||||
})
|
||||
|
||||
it('should display file name in uploading message', () => {
|
||||
const file = createMockFile('special-plugin.difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
// The message includes the file name as a parameter
|
||||
expect(screen.getByText(/plugin\.installModal\.uploadingPackage/)).toHaveTextContent('special-plugin.difypkg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty response gracefully', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: {},
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: undefined,
|
||||
manifest: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle response with only unique_identifier', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: { unique_identifier: 'only-uid' },
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'only-uid',
|
||||
manifest: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle file with special characters in name', () => {
|
||||
const file = createMockFile('my plugin (v1.0).difypkg')
|
||||
render(<Uploading {...defaultProps} file={file} />)
|
||||
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent('my plugin (v1.0).difypkg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Variations Tests
|
||||
// ================================
|
||||
describe('Props Variations', () => {
|
||||
it('should work with different file types', () => {
|
||||
const files = [
|
||||
createMockFile('plugin-a.difypkg'),
|
||||
createMockFile('plugin-b.zip'),
|
||||
createMockFile('bundle.difybndl'),
|
||||
]
|
||||
|
||||
files.forEach((file) => {
|
||||
const { unmount } = render(<Uploading {...defaultProps} file={file} />)
|
||||
expect(screen.getByTestId('card-name')).toHaveTextContent(file.name)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isBundle=false to uploadFile for package files', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} isBundle={false} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isBundle=true to uploadFile for bundle files', async () => {
|
||||
mockUploadFile.mockResolvedValue({})
|
||||
|
||||
render(<Uploading {...defaultProps} isBundle />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,928 @@
|
||||
import type { Dependency, Plugin, PluginManifestInMarket } from '../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../types'
|
||||
import InstallFromMarketplace from './index'
|
||||
|
||||
// Factory functions for test data
|
||||
// Use type casting to avoid strict locale requirements in tests
|
||||
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
|
||||
plugin_unique_identifier: 'test-unique-identifier',
|
||||
name: 'Test Plugin',
|
||||
org: 'test-org',
|
||||
icon: 'test-icon.png',
|
||||
label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'],
|
||||
category: PluginCategoryEnum.tool,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'],
|
||||
introduction: 'Introduction text',
|
||||
verified: true,
|
||||
install_count: 100,
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-package-id',
|
||||
icon: 'test-icon.png',
|
||||
verified: true,
|
||||
label: { en_US: 'Test Plugin' },
|
||||
brief: { en_US: 'A test plugin' },
|
||||
description: { en_US: 'A test plugin description' },
|
||||
introduction: 'Introduction text',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockDependencies = (): Dependency[] => [
|
||||
{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'test/plugin1',
|
||||
version: 'v1.0.0',
|
||||
package: 'plugin1.zip',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
plugin_unique_identifier: 'plugin-2-uid',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
let mockHideLogicState = {
|
||||
modalClassName: 'test-modal-class',
|
||||
foldAnimInto: vi.fn(),
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
vi.mock('../hooks/use-hide-logic', () => ({
|
||||
default: () => mockHideLogicState,
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./steps/install', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
onCancel,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
onStartToInstall,
|
||||
}: {
|
||||
uniqueIdentifier: string
|
||||
payload: PluginManifestInMarket | Plugin
|
||||
onCancel: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
onStartToInstall: () => void
|
||||
}) => (
|
||||
<div data-testid="install-step">
|
||||
<span data-testid="unique-identifier">{uniqueIdentifier}</span>
|
||||
<span data-testid="payload-name">{payload?.name}</span>
|
||||
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button>
|
||||
<button data-testid="install-success-btn" onClick={() => onInstalled()}>Install Success</button>
|
||||
<button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}>Install Success No Refresh</button>
|
||||
<button data-testid="install-fail-btn" onClick={() => onFailed('Installation failed')}>Install Fail</button>
|
||||
<button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}>Install Fail No Msg</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../install-bundle/ready-to-install', () => ({
|
||||
default: ({
|
||||
step,
|
||||
onStepChange,
|
||||
onStartToInstall,
|
||||
setIsInstalling,
|
||||
onClose,
|
||||
allPlugins,
|
||||
isFromMarketPlace,
|
||||
}: {
|
||||
step: InstallStep
|
||||
onStepChange: (step: InstallStep) => void
|
||||
onStartToInstall: () => void
|
||||
setIsInstalling: (isInstalling: boolean) => void
|
||||
onClose: () => void
|
||||
allPlugins: Dependency[]
|
||||
isFromMarketPlace?: boolean
|
||||
}) => (
|
||||
<div data-testid="bundle-step">
|
||||
<span data-testid="bundle-step-value">{step}</span>
|
||||
<span data-testid="bundle-plugins-count">{allPlugins?.length || 0}</span>
|
||||
<span data-testid="is-from-marketplace">{isFromMarketPlace ? 'true' : 'false'}</span>
|
||||
<button data-testid="bundle-cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button data-testid="bundle-start-install-btn" onClick={onStartToInstall}>Start Install</button>
|
||||
<button data-testid="bundle-set-installing-true" onClick={() => setIsInstalling(true)}>Set Installing True</button>
|
||||
<button data-testid="bundle-set-installing-false" onClick={() => setIsInstalling(false)}>Set Installing False</button>
|
||||
<button data-testid="bundle-change-to-installed" onClick={() => onStepChange(InstallStep.installed)}>Change to Installed</button>
|
||||
<button data-testid="bundle-change-to-failed" onClick={() => onStepChange(InstallStep.installFailed)}>Change to Failed</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../base/installed', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isMarketPayload,
|
||||
isFailed,
|
||||
errMsg,
|
||||
onCancel,
|
||||
}: {
|
||||
payload: PluginManifestInMarket | Plugin | null
|
||||
isMarketPayload?: boolean
|
||||
isFailed: boolean
|
||||
errMsg?: string | null
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="installed-step">
|
||||
<span data-testid="installed-payload">{payload?.name || 'no-payload'}</span>
|
||||
<span data-testid="is-market-payload">{isMarketPayload ? 'true' : 'false'}</span>
|
||||
<span data-testid="is-failed">{isFailed ? 'true' : 'false'}</span>
|
||||
<span data-testid="error-msg">{errMsg || 'no-error'}</span>
|
||||
<button data-testid="installed-close-btn" onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InstallFromMarketplace', () => {
|
||||
const defaultProps = {
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
manifest: createMockManifest(),
|
||||
onSuccess: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHideLogicState = {
|
||||
modalClassName: 'test-modal-class',
|
||||
foldAnimInto: vi.fn(),
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render modal with correct initial state for single plugin', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with bundle step when isBundle is true', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should pass isFromMarketPlace as true to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('is-from-marketplace')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass correct props to Install component', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('test-unique-identifier')
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should apply modal className from useHideLogic', () => {
|
||||
expect(mockHideLogicState.modalClassName).toBe('test-modal-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Title Display Tests
|
||||
// ================================
|
||||
describe('Title Display', () => {
|
||||
it('should show install title in readyToInstall step', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success title when installation completes for single plugin', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show bundle complete title when bundle installation completes', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show failed title when installation fails', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
describe('State Management', () => {
|
||||
it('should transition from readyToInstall to installed on success', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should transition from readyToInstall to installFailed on failure', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle failure without error message', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-no-msg-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('error-msg')).toHaveTextContent('no-error')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update step via onStepChange in bundle mode', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Callback Stability Tests (Memoization)
|
||||
// ================================
|
||||
describe('Callback Stability', () => {
|
||||
it('should maintain stable getTitle callback across rerenders', () => {
|
||||
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
|
||||
rerender(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain stable handleInstalled callback', async () => {
|
||||
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
rerender(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain stable handleFailed callback', async () => {
|
||||
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
rerender(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when cancel is clicked', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-btn'))
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call foldAnimInto when modal close is triggered', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(mockHideLogicState.foldAnimInto).toBeDefined()
|
||||
})
|
||||
|
||||
it('should call handleStartToInstall when start install is triggered', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('start-install-btn'))
|
||||
|
||||
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSuccess when close button is clicked in installed step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('installed-close-btn'))
|
||||
|
||||
expect(defaultProps.onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose in bundle mode cancel', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-cancel-btn'))
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Refresh Plugin List Tests
|
||||
// ================================
|
||||
describe('Refresh Plugin List', () => {
|
||||
it('should call refreshPluginList when installation completes without notRefresh flag', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshPluginList).toHaveBeenCalledWith(defaultProps.manifest)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call refreshPluginList when notRefresh flag is true', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-no-refresh-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshPluginList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// setIsInstalling Tests
|
||||
// ================================
|
||||
describe('setIsInstalling Behavior', () => {
|
||||
it('should call setIsInstalling(false) when installation completes', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setIsInstalling(false) when installation fails', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass setIsInstalling to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-set-installing-true'))
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-set-installing-false'))
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installed Component Props Tests
|
||||
// ================================
|
||||
describe('Installed Component Props', () => {
|
||||
it('should pass isMarketPayload as true to Installed component', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('is-market-payload')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct payload to Installed component', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-payload')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isFailed as true when installation fails', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass error message to Installed component on failure', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Prop Variations Tests
|
||||
// ================================
|
||||
describe('Prop Variations', () => {
|
||||
it('should work with Plugin type manifest', () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
manifest={plugin}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should work with PluginManifestInMarket type manifest', () => {
|
||||
const manifest = createMockManifest({ name: 'Market Plugin' })
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
manifest={manifest}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Market Plugin')
|
||||
})
|
||||
|
||||
it('should handle different uniqueIdentifier values', () => {
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
uniqueIdentifier="custom-unique-id-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('custom-unique-id-123')
|
||||
})
|
||||
|
||||
it('should work without isBundle prop (default to single plugin)', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with isBundle=false', () => {
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with empty dependencies array in bundle mode', () => {
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle manifest with minimal required fields', () => {
|
||||
const minimalManifest = createMockManifest({
|
||||
name: 'Minimal',
|
||||
version: '0.0.1',
|
||||
})
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
manifest={minimalManifest}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Minimal')
|
||||
})
|
||||
|
||||
it('should handle multiple rapid state transitions', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
// Trigger installation completion
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should stay in installed state
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle bundle mode step changes', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change to installed step
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle bundle mode failure step change', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-failed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Install component in terminal steps', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Installed component for success state with isFailed false', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Installed component for failure state with isFailed true', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Terminal Steps Rendering Tests
|
||||
// ================================
|
||||
describe('Terminal Steps Rendering', () => {
|
||||
it('should render Installed component when step is installed', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Installed component when step is installFailed', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Install component when in terminal step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
// Initially Install is shown
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Data Flow Tests
|
||||
// ================================
|
||||
describe('Data Flow', () => {
|
||||
it('should pass uniqueIdentifier to Install component', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} uniqueIdentifier="flow-test-id" />)
|
||||
|
||||
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('flow-test-id')
|
||||
})
|
||||
|
||||
it('should pass manifest payload to Install component', () => {
|
||||
const customManifest = createMockManifest({ name: 'Flow Test Plugin' })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={customManifest} />)
|
||||
|
||||
expect(screen.getByTestId('payload-name')).toHaveTextContent('Flow Test Plugin')
|
||||
})
|
||||
|
||||
it('should pass dependencies to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should pass current step to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('bundle-step-value')).toHaveTextContent(InstallStep.readyToInstall)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Manifest Category Variations Tests
|
||||
// ================================
|
||||
describe('Manifest Category Variations', () => {
|
||||
it('should handle tool category manifest', () => {
|
||||
const manifest = createMockManifest({ category: PluginCategoryEnum.tool })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle model category manifest', () => {
|
||||
const manifest = createMockManifest({ category: PluginCategoryEnum.model })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle extension category manifest', () => {
|
||||
const manifest = createMockManifest({ category: PluginCategoryEnum.extension })
|
||||
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('install-step')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Hook Integration Tests
|
||||
// ================================
|
||||
describe('Hook Integration', () => {
|
||||
it('should use handleStartToInstall from useHideLogic', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('start-install-btn'))
|
||||
|
||||
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use setIsInstalling from useHideLogic in handleInstalled', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use setIsInstalling from useHideLogic in handleFailed', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use refreshPluginList from useRefreshPluginList', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// getTitle Memoization Tests
|
||||
// ================================
|
||||
describe('getTitle Memoization', () => {
|
||||
it('should return installPlugin title for readyToInstall step', () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return installedSuccessfully for non-bundle installed step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-success-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return installComplete for bundle installed step', async () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return installFailed for installFailed step', async () => {
|
||||
render(<InstallFromMarketplace {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('install-fail-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,729 @@
|
||||
import type { Plugin, PluginManifestInMarket } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../types'
|
||||
import Install from './install'
|
||||
|
||||
// Factory functions for test data
|
||||
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
|
||||
plugin_unique_identifier: 'test-unique-identifier',
|
||||
name: 'Test Plugin',
|
||||
org: 'test-org',
|
||||
icon: 'test-icon.png',
|
||||
label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'],
|
||||
category: PluginCategoryEnum.tool,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'],
|
||||
introduction: 'Introduction text',
|
||||
verified: true,
|
||||
install_count: 100,
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-package-id',
|
||||
icon: 'test-icon.png',
|
||||
verified: true,
|
||||
label: { en_US: 'Test Plugin' },
|
||||
brief: { en_US: 'A test plugin' },
|
||||
description: { en_US: 'A test plugin description' },
|
||||
introduction: 'Introduction text',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock variables for controlling test behavior
|
||||
let mockInstalledInfo: Record<string, { installedId: string, installedVersion: string, uniqueIdentifier: string }> | undefined
|
||||
let mockIsLoading = false
|
||||
const mockInstallPackageFromMarketPlace = vi.fn()
|
||||
const mockUpdatePackageFromMarketPlace = vi.fn()
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
const mockStopTaskStatus = vi.fn()
|
||||
const mockHandleRefetch = vi.fn()
|
||||
let mockPluginDeclaration: { manifest: { meta: { minimum_dify_version: string } } } | undefined
|
||||
let mockCanInstall = true
|
||||
let mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
|
||||
// Mock useCheckInstalled
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: ({ pluginIds }: { pluginIds: string[], enabled: boolean }) => ({
|
||||
installedInfo: mockInstalledInfo,
|
||||
isLoading: mockIsLoading,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockInstallPackageFromMarketPlace,
|
||||
}),
|
||||
useUpdatePackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockUpdatePackageFromMarketPlace,
|
||||
}),
|
||||
usePluginDeclarationFromMarketPlace: () => ({
|
||||
data: mockPluginDeclaration,
|
||||
}),
|
||||
usePluginTaskList: () => ({
|
||||
handleRefetch: mockHandleRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock checkTaskStatus
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheckTaskStatus,
|
||||
stop: mockStopTaskStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useAppContext
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
langGeniusVersionInfo: mockLangGeniusVersionInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useInstallPluginLimit
|
||||
vi.mock('../../hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({ canInstall: mockCanInstall }),
|
||||
}))
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft, className, limitedInstall }: {
|
||||
payload: any
|
||||
titleLeft?: React.ReactNode
|
||||
className?: string
|
||||
limitedInstall?: boolean
|
||||
}) => (
|
||||
<div data-testid="plugin-card">
|
||||
<span data-testid="card-payload-name">{payload?.name}</span>
|
||||
<span data-testid="card-limited-install">{limitedInstall ? 'true' : 'false'}</span>
|
||||
{titleLeft && <div data-testid="card-title-left">{titleLeft}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Version component
|
||||
vi.mock('../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
toInstallVersion: string
|
||||
}) => (
|
||||
<div data-testid="version-component">
|
||||
<span data-testid="has-installed">{hasInstalled ? 'true' : 'false'}</span>
|
||||
<span data-testid="installed-version">{installedVersion || 'none'}</span>
|
||||
<span data-testid="to-install-version">{toInstallVersion}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock utils
|
||||
vi.mock('../../utils', () => ({
|
||||
pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({
|
||||
name: payload.name,
|
||||
icon: payload.icon,
|
||||
category: payload.category,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Install Component (steps/install.tsx)', () => {
|
||||
const defaultProps = {
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
payload: createMockManifest(),
|
||||
onCancel: vi.fn(),
|
||||
onStartToInstall: vi.fn(),
|
||||
onInstalled: vi.fn(),
|
||||
onFailed: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInstalledInfo = undefined
|
||||
mockIsLoading = false
|
||||
mockPluginDeclaration = undefined
|
||||
mockCanInstall = true
|
||||
mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
mockUpdatePackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-456',
|
||||
})
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
status: TaskStatus.success,
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render ready to install text', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card with correct payload', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Test Plugin')
|
||||
})
|
||||
|
||||
it('should render cancel button when not installing', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render install button', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.install')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render version component while loading', () => {
|
||||
mockIsLoading = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('version-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render version component when not loading', () => {
|
||||
mockIsLoading = false
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('version-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Version Display Tests
|
||||
// ================================
|
||||
describe('Version Display', () => {
|
||||
it('should show hasInstalled as false when not installed', () => {
|
||||
mockInstalledInfo = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should show hasInstalled as true when already installed', () => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'old-unique-id',
|
||||
},
|
||||
}
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('installed-version')).toHaveTextContent('0.9.0')
|
||||
})
|
||||
|
||||
it('should show correct toInstallVersion from payload.version', () => {
|
||||
const manifest = createMockManifest({ version: '2.0.0' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('to-install-version')).toHaveTextContent('2.0.0')
|
||||
})
|
||||
|
||||
it('should fallback to latest_version when version is undefined', () => {
|
||||
const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Version Compatibility Tests
|
||||
// ================================
|
||||
describe('Version Compatibility', () => {
|
||||
it('should not show warning when no plugin declaration', () => {
|
||||
mockPluginDeclaration = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show warning when dify version is compatible', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: '2.0.0' }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '1.0.0' } },
|
||||
}
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when dify version is incompatible', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: '1.0.0' }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '2.0.0' } },
|
||||
}
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Install Limit Tests
|
||||
// ================================
|
||||
describe('Install Limit', () => {
|
||||
it('should pass limitedInstall=false to Card when canInstall is true', () => {
|
||||
mockCanInstall = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-limited-install')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass limitedInstall=true to Card when canInstall is false', () => {
|
||||
mockCanInstall = false
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('card-limited-install')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should disable install button when canInstall is false', () => {
|
||||
mockCanInstall = false
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Button States Tests
|
||||
// ================================
|
||||
describe('Button States', () => {
|
||||
it('should disable install button when loading', () => {
|
||||
mockIsLoading = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable install button when not loading and canInstall', () => {
|
||||
mockIsLoading = false
|
||||
mockCanInstall = true
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
|
||||
expect(installBtn).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Cancel Button Tests
|
||||
// ================================
|
||||
describe('Cancel Button', () => {
|
||||
it('should call onCancel and stop when cancel is clicked', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
expect(mockStopTaskStatus).toHaveBeenCalled()
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// New Installation Flow Tests
|
||||
// ================================
|
||||
describe('New Installation Flow', () => {
|
||||
it('should call onStartToInstall when install button is clicked', async () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
expect(defaultProps.onStartToInstall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call installPackageFromMarketPlace for new installation', async () => {
|
||||
mockInstalledInfo = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('test-unique-identifier')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled immediately when all_installed is true', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onInstalled).toHaveBeenCalled()
|
||||
expect(mockCheckTaskStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check task status when all_installed is false', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-123',
|
||||
})
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefetch).toHaveBeenCalled()
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledWith({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test-unique-identifier',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onInstalled with true when task succeeds', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success })
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onInstalled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed when task fails', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
status: TaskStatus.failed,
|
||||
error: 'Task failed error',
|
||||
})
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onFailed).toHaveBeenCalledWith('Task failed error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Update Installation Flow Tests
|
||||
// ================================
|
||||
describe('Update Installation Flow', () => {
|
||||
beforeEach(() => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'old-unique-id',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('should call updatePackageFromMarketPlace for update installation', async () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePackageFromMarketPlace).toHaveBeenCalledWith({
|
||||
original_plugin_unique_identifier: 'old-unique-id',
|
||||
new_plugin_unique_identifier: 'test-unique-identifier',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call installPackageFromMarketPlace when updating', async () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Auto-Install on Already Installed Tests
|
||||
// ================================
|
||||
describe('Auto-Install on Already Installed', () => {
|
||||
it('should call onInstalled when already installed with same uniqueIdentifier', async () => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'test-unique-identifier',
|
||||
},
|
||||
}
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onInstalled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-install when uniqueIdentifier differs', async () => {
|
||||
mockInstalledInfo = {
|
||||
'test-plugin-id': {
|
||||
installedId: 'install-id',
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'different-unique-id',
|
||||
},
|
||||
}
|
||||
const plugin = createMockPlugin()
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
// Wait a bit to ensure onInstalled is not called
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(defaultProps.onInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error Handling Tests
|
||||
// ================================
|
||||
describe('Error Handling', () => {
|
||||
it('should call onFailed with string error', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockRejectedValue('String error message')
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onFailed).toHaveBeenCalledWith('String error message')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onFailed without message for non-string error', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockRejectedValue(new Error('Error object'))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onFailed).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Installing State Tests
|
||||
// ================================
|
||||
describe('Installing State', () => {
|
||||
it('should hide cancel button while installing', async () => {
|
||||
// Make the install take some time
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show installing text while installing', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable install button while installing', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const installBtn = screen.getByText('plugin.installModal.installing').closest('button')
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger multiple installs when clicking rapidly', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
const installBtn = screen.getByText('plugin.installModal.install').closest('button')!
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(installBtn)
|
||||
})
|
||||
|
||||
// Wait for the button to be disabled
|
||||
await waitFor(() => {
|
||||
expect(installBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
// Try clicking again - should not trigger another install
|
||||
await act(async () => {
|
||||
fireEvent.click(installBtn)
|
||||
fireEvent.click(installBtn)
|
||||
})
|
||||
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Prop Variations Tests
|
||||
// ================================
|
||||
describe('Prop Variations', () => {
|
||||
it('should work with PluginManifestInMarket payload', () => {
|
||||
const manifest = createMockManifest({ name: 'Manifest Plugin' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Manifest Plugin')
|
||||
})
|
||||
|
||||
it('should work with Plugin payload', () => {
|
||||
const plugin = createMockPlugin({ name: 'Plugin Type' })
|
||||
render(<Install {...defaultProps} payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Plugin Type')
|
||||
})
|
||||
|
||||
it('should work without onStartToInstall callback', async () => {
|
||||
const propsWithoutCallback = {
|
||||
...defaultProps,
|
||||
onStartToInstall: undefined,
|
||||
}
|
||||
render(<Install {...propsWithoutCallback} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
// Should not throw and should proceed with installation
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle different uniqueIdentifier values', async () => {
|
||||
render(<Install {...defaultProps} uniqueIdentifier="custom-id-123" />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('plugin.installModal.install'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('custom-id-123')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty plugin_id gracefully', () => {
|
||||
const manifest = createMockManifest()
|
||||
// Manifest doesn't have plugin_id, so installedInfo won't match
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle undefined installedInfo', () => {
|
||||
mockInstalledInfo = undefined
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle null current_version in langGeniusVersionInfo', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: null as any }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '1.0.0' } },
|
||||
}
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
// Should not show warning when current_version is null (defaults to compatible)
|
||||
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Component Memoization Tests
|
||||
// ================================
|
||||
describe('Component Memoization', () => {
|
||||
it('should maintain stable component across rerenders with same props', () => {
|
||||
const { rerender } = render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
|
||||
rerender(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,683 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Import component after mocks are set up
|
||||
import Description from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies
|
||||
// ================================
|
||||
|
||||
// Track mock locale for testing
|
||||
let mockDefaultLocale = 'en-US'
|
||||
|
||||
// Mock translations with realistic values
|
||||
const pluginTranslations: Record<string, string> = {
|
||||
'marketplace.empower': 'Empower your AI development',
|
||||
'marketplace.discover': 'Discover',
|
||||
'marketplace.difyMarketplace': 'Dify Marketplace',
|
||||
'marketplace.and': 'and',
|
||||
'category.models': 'Models',
|
||||
'category.tools': 'Tools',
|
||||
'category.datasources': 'Data Sources',
|
||||
'category.triggers': 'Triggers',
|
||||
'category.agents': 'Agent Strategies',
|
||||
'category.extensions': 'Extensions',
|
||||
'category.bundles': 'Bundles',
|
||||
}
|
||||
|
||||
const commonTranslations: Record<string, string> = {
|
||||
'operation.in': 'in',
|
||||
}
|
||||
|
||||
// Mock getLocaleOnServer and translate
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: vi.fn(() => Promise.resolve(mockDefaultLocale)),
|
||||
getTranslation: vi.fn((locale: string, ns: string) => {
|
||||
return Promise.resolve({
|
||||
t: (key: string) => {
|
||||
if (ns === 'plugin')
|
||||
return pluginTranslations[key] || key
|
||||
if (ns === 'common')
|
||||
return commonTranslations[key] || key
|
||||
return key
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Description Component Tests
|
||||
// ================================
|
||||
describe('Description', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render h1 heading with empower text', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Empower your AI development')
|
||||
})
|
||||
|
||||
it('should render h2 subheading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h1', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('title-4xl-semi-bold')
|
||||
expect(heading).toHaveClass('mb-2')
|
||||
expect(heading).toHaveClass('text-center')
|
||||
expect(heading).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('body-md-regular')
|
||||
expect(subheading).toHaveClass('text-center')
|
||||
expect(subheading).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Non-Chinese Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Non-Chinese Locale Rendering', () => {
|
||||
it('should render discover text for en-US locale', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all category names', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "and" conjunction text', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
|
||||
it('should render "in" preposition at the end for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText('in')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text at the end for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render category spans with styled underline effect', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
|
||||
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
|
||||
expect(styledSpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class to category spans', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const styledSpans = container.querySelectorAll('.text-text-secondary')
|
||||
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Chinese (zh-Hans) Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Chinese (zh-Hans) Locale Rendering', () => {
|
||||
it('should render "in" text at the beginning for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// In zh-Hans mode, "in" appears at the beginning
|
||||
const inElements = screen.getAllByText('in')
|
||||
expect(inElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render discover text for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all categories for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both zh-Hans specific elements and shared elements', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
|
||||
// then the same category list with "and" -> Bundles
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Variations Tests
|
||||
// ================================
|
||||
describe('Locale Prop Variations', () => {
|
||||
it('should use default locale when locale prop is undefined', async () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(await Description({}))
|
||||
|
||||
// Should use the default locale from getLocaleOnServer
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use provided locale prop instead of default', async () => {
|
||||
mockDefaultLocale = 'ja-JP'
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// The locale prop should be used, triggering non-Chinese rendering
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'ja-JP' }))
|
||||
|
||||
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should handle ko-KR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'ko-KR' }))
|
||||
|
||||
// Should render in non-Chinese format
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle de-DE locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'de-DE' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle fr-FR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'fr-FR' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pt-BR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'pt-BR' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle es-ES locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'es-ES' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Conditional Rendering Tests
|
||||
// ================================
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should render zh-Hans specific content when locale is zh-Hans', async () => {
|
||||
const { container } = render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// zh-Hans has additional span with mr-1 before "in" text at the start
|
||||
const mrSpan = container.querySelector('span.mr-1')
|
||||
expect(mrSpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render non-Chinese specific content when locale is not zh-Hans', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// Non-Chinese has "in" and "Dify Marketplace" at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should not render zh-Hans intro content for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// For en-US, the order should be Discover ... in Dify Marketplace
|
||||
// The "in" text should only appear once at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "in" should appear after "Bundles" and before "Dify Marketplace"
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
expect(bundlesIndex).toBeLessThan(inIndex)
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
|
||||
it('should render zh-Hans with proper word order', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Category Styling Tests
|
||||
// ================================
|
||||
describe('Category Styling', () => {
|
||||
it('should apply underline effect with after pseudo-element styling', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpan = container.querySelector('.after\\:absolute')
|
||||
expect(categorySpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct after pseudo-element classes', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Check for the specific after pseudo-element classes
|
||||
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply full width to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:w-full')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply correct height to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:h-2')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply bg-text-text-selected to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should have z-index 1 on category spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply left margin to category spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.ml-1')
|
||||
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('should apply both left and right margin to specific spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Extensions and Bundles spans have both ml-1 and mr-1
|
||||
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
|
||||
expect(extensionsBundlesSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty props object', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fragment as root element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Fragment renders h1 and h2 as direct children
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle locale prop with undefined value', async () => {
|
||||
render(await Description({ locale: undefined }))
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zh-Hant as non-Chinese simplified', async () => {
|
||||
render(await Description({ locale: 'zh-Hant' }))
|
||||
|
||||
// zh-Hant is different from zh-Hans, should use non-Chinese format
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Check that "Dify Marketplace" appears at the end (non-Chinese format)
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
// For non-Chinese locales, Discover should come before Dify Marketplace
|
||||
expect(discoverIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Content Structure Tests
|
||||
// ================================
|
||||
describe('Content Structure', () => {
|
||||
it('should have comma separators between categories', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Commas should exist between categories
|
||||
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
|
||||
})
|
||||
|
||||
it('should have "and" before last category (Bundles)', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "and" should appear before Bundles
|
||||
const andIndex = content.indexOf('and')
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
|
||||
expect(andIndex).toBeLessThan(bundlesIndex)
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for en-US', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
const expectedOrder = [
|
||||
'Discover',
|
||||
'Models',
|
||||
'Tools',
|
||||
'Data Sources',
|
||||
'Triggers',
|
||||
'Agent Strategies',
|
||||
'Extensions',
|
||||
'and',
|
||||
'Bundles',
|
||||
'in',
|
||||
'Dify Marketplace',
|
||||
]
|
||||
|
||||
let lastIndex = -1
|
||||
for (const text of expectedOrder) {
|
||||
const currentIndex = content.indexOf(text)
|
||||
expect(currentIndex).toBeGreaterThan(lastIndex)
|
||||
lastIndex = currentIndex
|
||||
}
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for zh-Hans', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const modelsIndex = content.indexOf('Models')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
expect(discoverIndex).toBeLessThan(modelsIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Layout Tests
|
||||
// ================================
|
||||
describe('Layout', () => {
|
||||
it('should have shrink-0 on h1 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on h2 subheading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have flex layout on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('flex')
|
||||
})
|
||||
|
||||
it('should have items-center on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should have justify-center on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('justify-center')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Function Tests
|
||||
// ================================
|
||||
describe('Translation Functions', () => {
|
||||
it('should call getTranslation for plugin namespace', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('en-US', 'plugin')
|
||||
})
|
||||
|
||||
it('should call getTranslation for common namespace', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('en-US', 'common')
|
||||
})
|
||||
|
||||
it('should call getLocaleOnServer when locale prop is undefined', async () => {
|
||||
const { getLocaleOnServer } = await import('@/i18n-config/server')
|
||||
render(await Description({}))
|
||||
|
||||
expect(getLocaleOnServer).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use locale prop when provided', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'ja-JP' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'plugin')
|
||||
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'common')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
const h2 = screen.getByRole('heading', { level: 2 })
|
||||
|
||||
expect(h1).toBeInTheDocument()
|
||||
expect(h2).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have readable text content', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
expect(h1.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('should have visible h1 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
it('should have visible h2 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Description Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
it('should render complete component structure', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should render complete zh-Hans structure', async () => {
|
||||
const { container } = render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should correctly switch between zh-Hans and en-US layouts', async () => {
|
||||
// Render en-US
|
||||
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
|
||||
const enContent = enContainer.querySelector('h2')?.textContent || ''
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
|
||||
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
|
||||
|
||||
// Both should have all categories
|
||||
expect(enContent).toContain('Models')
|
||||
expect(zhContent).toContain('Models')
|
||||
|
||||
// But order should differ
|
||||
const enMarketplaceIndex = enContent.indexOf('Dify Marketplace')
|
||||
const enDiscoverIndex = enContent.indexOf('Discover')
|
||||
const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace')
|
||||
const zhDiscoverIndex = zhContent.indexOf('Discover')
|
||||
|
||||
// en-US: Discover comes before Dify Marketplace
|
||||
expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex)
|
||||
|
||||
// zh-Hans: Dify Marketplace comes before Discover
|
||||
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
|
||||
})
|
||||
|
||||
it('should maintain consistent styling across locales', async () => {
|
||||
// Render en-US
|
||||
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
|
||||
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
|
||||
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
|
||||
|
||||
// Both should have same number of styled category spans
|
||||
expect(enCategoryCount).toBe(zhCategoryCount)
|
||||
expect(enCategoryCount).toBe(7)
|
||||
})
|
||||
})
|
||||
836
web/app/components/plugins/marketplace/empty/index.spec.tsx
Normal file
836
web/app/components/plugins/marketplace/empty/index.spec.tsx
Normal file
@ -0,0 +1,836 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Empty from './index'
|
||||
import Line from './line'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.noPluginFound': 'No plugin found',
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTheme hook with controllable theme value
|
||||
let mockTheme = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Line Component Tests
|
||||
// ================================
|
||||
describe('Line', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SVG element', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Light Theme Tests
|
||||
// ================================
|
||||
describe('Light Theme', () => {
|
||||
beforeEach(() => {
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render light mode SVG', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '241')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 2 241')
|
||||
})
|
||||
|
||||
it('should render light mode path with correct d attribute', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('d', 'M1 0.5L1 240.5')
|
||||
})
|
||||
|
||||
it('should render light mode linear gradient with correct id', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const gradient = container.querySelector('#paint0_linear_1989_74474')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render light mode gradient with white stop colors', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops.length).toBe(3)
|
||||
|
||||
// First stop - white with 0.01 opacity
|
||||
expect(stops[0]).toHaveAttribute('stop-color', 'white')
|
||||
expect(stops[0]).toHaveAttribute('stop-opacity', '0.01')
|
||||
|
||||
// Middle stop - dark color with 0.08 opacity
|
||||
expect(stops[1]).toHaveAttribute('stop-color', '#101828')
|
||||
expect(stops[1]).toHaveAttribute('stop-opacity', '0.08')
|
||||
|
||||
// Last stop - white with 0.01 opacity
|
||||
expect(stops[2]).toHaveAttribute('stop-color', 'white')
|
||||
expect(stops[2]).toHaveAttribute('stop-opacity', '0.01')
|
||||
})
|
||||
|
||||
it('should apply className to SVG in light mode', () => {
|
||||
const { container } = render(<Line className="test-class" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('test-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dark Theme Tests
|
||||
// ================================
|
||||
describe('Dark Theme', () => {
|
||||
beforeEach(() => {
|
||||
mockTheme = 'dark'
|
||||
})
|
||||
|
||||
it('should render dark mode SVG', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '240')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 2 240')
|
||||
})
|
||||
|
||||
it('should render dark mode path with correct d attribute', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('d', 'M1 0L1 240')
|
||||
})
|
||||
|
||||
it('should render dark mode linear gradient with correct id', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const gradient = container.querySelector('#paint0_linear_6295_52176')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dark mode gradient stops', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops.length).toBe(3)
|
||||
|
||||
// First stop - no color, 0.01 opacity
|
||||
expect(stops[0]).toHaveAttribute('stop-opacity', '0.01')
|
||||
|
||||
// Middle stop - light color with 0.14 opacity
|
||||
expect(stops[1]).toHaveAttribute('stop-color', '#C8CEDA')
|
||||
expect(stops[1]).toHaveAttribute('stop-opacity', '0.14')
|
||||
|
||||
// Last stop - no color, 0.01 opacity
|
||||
expect(stops[2]).toHaveAttribute('stop-opacity', '0.01')
|
||||
})
|
||||
|
||||
it('should apply className to SVG in dark mode', () => {
|
||||
const { container } = render(<Line className="dark-test-class" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('dark-test-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Variations Tests
|
||||
// ================================
|
||||
describe('Props Variations', () => {
|
||||
it('should handle undefined className', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
const { container } = render(<Line className="" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple class names', () => {
|
||||
const { container } = render(<Line className="class-1 class-2 class-3" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('class-1')
|
||||
expect(svg).toHaveClass('class-2')
|
||||
expect(svg).toHaveClass('class-3')
|
||||
})
|
||||
|
||||
it('should handle Tailwind utility classes', () => {
|
||||
const { container } = render(
|
||||
<Line className="absolute right-[-1px] top-1/2 -translate-y-1/2" />,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('absolute')
|
||||
expect(svg).toHaveClass('right-[-1px]')
|
||||
expect(svg).toHaveClass('top-1/2')
|
||||
expect(svg).toHaveClass('-translate-y-1/2')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Theme Switching Tests
|
||||
// ================================
|
||||
describe('Theme Switching', () => {
|
||||
it('should render different SVG dimensions based on theme', () => {
|
||||
// Light mode
|
||||
mockTheme = 'light'
|
||||
const { container: lightContainer, unmount: unmountLight } = render(<Line />)
|
||||
expect(lightContainer.querySelector('svg')).toHaveAttribute('height', '241')
|
||||
unmountLight()
|
||||
|
||||
// Dark mode
|
||||
mockTheme = 'dark'
|
||||
const { container: darkContainer } = render(<Line />)
|
||||
expect(darkContainer.querySelector('svg')).toHaveAttribute('height', '240')
|
||||
})
|
||||
|
||||
it('should use different gradient IDs based on theme', () => {
|
||||
// Light mode
|
||||
mockTheme = 'light'
|
||||
const { container: lightContainer, unmount: unmountLight } = render(<Line />)
|
||||
expect(lightContainer.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument()
|
||||
expect(lightContainer.querySelector('#paint0_linear_6295_52176')).not.toBeInTheDocument()
|
||||
unmountLight()
|
||||
|
||||
// Dark mode
|
||||
mockTheme = 'dark'
|
||||
const { container: darkContainer } = render(<Line />)
|
||||
expect(darkContainer.querySelector('#paint0_linear_6295_52176')).toBeInTheDocument()
|
||||
expect(darkContainer.querySelector('#paint0_linear_1989_74474')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle theme value of light explicitly', () => {
|
||||
mockTheme = 'light'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
expect(container.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle non-dark theme as light mode', () => {
|
||||
mockTheme = 'system'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
// Non-dark themes should use light mode SVG
|
||||
expect(container.querySelector('svg')).toHaveAttribute('height', '241')
|
||||
})
|
||||
|
||||
it('should render SVG with fill none', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render path with gradient stroke', () => {
|
||||
mockTheme = 'light'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_1989_74474)')
|
||||
})
|
||||
|
||||
it('should render dark mode path with gradient stroke', () => {
|
||||
mockTheme = 'dark'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_6295_52176)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Empty Component Tests
|
||||
// ================================
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 16 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should render default no plugin found text', () => {
|
||||
render(<Empty />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Group icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Icon wrapper should be present
|
||||
const iconWrapper = container.querySelector('.h-14.w-14')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render four Line components around the icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Four SVG elements from Line components + 1 Group icon SVG = 5 total
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render center content with absolute positioning', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerContent = container.querySelector('.absolute.left-1\\/2.top-1\\/2')
|
||||
expect(centerContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Text Prop Tests
|
||||
// ================================
|
||||
describe('Text Prop', () => {
|
||||
it('should render custom text when provided', () => {
|
||||
render(<Empty text="Custom empty message" />)
|
||||
|
||||
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No plugin found')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default translation when text is empty string', () => {
|
||||
render(<Empty text="" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default translation when text is undefined', () => {
|
||||
render(<Empty text={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render long custom text', () => {
|
||||
const longText = 'This is a very long message that describes why there are no plugins found in the current search results and what the user might want to do next to find what they are looking for'
|
||||
render(<Empty text={longText} />)
|
||||
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text with special characters', () => {
|
||||
render(<Empty text="No plugins found for query: <search>" />)
|
||||
|
||||
expect(screen.getByText('No plugins found for query: <search>')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// LightCard Prop Tests
|
||||
// ================================
|
||||
describe('LightCard Prop', () => {
|
||||
it('should render overlay when lightCard is false', () => {
|
||||
const { container } = render(<Empty lightCard={false} />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overlay when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overlay by default when lightCard is undefined', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply light card styling to placeholder cards when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should apply default styling to placeholder cards when lightCard is false', () => {
|
||||
const { container } = render(<Empty lightCard={false} />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-section-burn')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should apply opacity to light card placeholder', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.opacity-75')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ClassName Prop Tests
|
||||
// ================================
|
||||
describe('ClassName Prop', () => {
|
||||
it('should apply custom className to container', () => {
|
||||
const { container } = render(<Empty className="custom-class" />)
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve base classes when adding custom className', () => {
|
||||
const { container } = render(<Empty className="custom-class" />)
|
||||
|
||||
const element = container.querySelector('.custom-class')
|
||||
expect(element).toHaveClass('relative')
|
||||
expect(element).toHaveClass('flex')
|
||||
expect(element).toHaveClass('h-0')
|
||||
expect(element).toHaveClass('grow')
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
const { container } = render(<Empty className="" />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined className', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const element = container.firstChild as HTMLElement
|
||||
expect(element).toHaveClass('relative')
|
||||
})
|
||||
|
||||
it('should handle multiple custom classes', () => {
|
||||
const { container } = render(<Empty className="class-a class-b class-c" />)
|
||||
|
||||
const element = container.querySelector('.class-a')
|
||||
expect(element).toHaveClass('class-b')
|
||||
expect(element).toHaveClass('class-c')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Tests
|
||||
// ================================
|
||||
describe('Locale Prop', () => {
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
// Translation should still work
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined locale', () => {
|
||||
render(<Empty locale={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle en-US locale', () => {
|
||||
render(<Empty locale="en-US" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale', () => {
|
||||
render(<Empty locale="ja-JP" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Placeholder Cards Layout Tests
|
||||
// ================================
|
||||
describe('Placeholder Cards Layout', () => {
|
||||
it('should remove right margin on every 4th card', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 3, 7, 11, 15 (4th, 8th, 12th, 16th) should have mr-0
|
||||
expect(cards[3]).toHaveClass('mr-0')
|
||||
expect(cards[7]).toHaveClass('mr-0')
|
||||
expect(cards[11]).toHaveClass('mr-0')
|
||||
expect(cards[15]).toHaveClass('mr-0')
|
||||
})
|
||||
|
||||
it('should have margin on cards that are not at the end of row', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards not at row end should have mr-3
|
||||
expect(cards[0]).toHaveClass('mr-3')
|
||||
expect(cards[1]).toHaveClass('mr-3')
|
||||
expect(cards[2]).toHaveClass('mr-3')
|
||||
})
|
||||
|
||||
it('should remove bottom margin on last row cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 12, 13, 14, 15 should have mb-0
|
||||
expect(cards[12]).toHaveClass('mb-0')
|
||||
expect(cards[13]).toHaveClass('mb-0')
|
||||
expect(cards[14]).toHaveClass('mb-0')
|
||||
expect(cards[15]).toHaveClass('mb-0')
|
||||
})
|
||||
|
||||
it('should have bottom margin on non-last row cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 0-11 should have mb-3
|
||||
expect(cards[0]).toHaveClass('mb-3')
|
||||
expect(cards[5]).toHaveClass('mb-3')
|
||||
expect(cards[11]).toHaveClass('mb-3')
|
||||
})
|
||||
|
||||
it('should have correct width calculation for 4 columns', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.w-\\[calc\\(\\(100\\%-36px\\)\\/4\\)\\]')
|
||||
expect(cards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should have rounded corners on cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.rounded-xl')
|
||||
// 16 cards + 1 icon wrapper = 17 rounded-xl elements
|
||||
expect(cards.length).toBeGreaterThanOrEqual(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Icon Container Tests
|
||||
// ================================
|
||||
describe('Icon Container', () => {
|
||||
it('should render icon container with border', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const iconContainer = container.querySelector('.border-dashed')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container with shadow', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const iconContainer = container.querySelector('.shadow-lg')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container centered', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerWrapper = container.querySelector('.-translate-x-1\\/2.-translate-y-1\\/2')
|
||||
expect(centerWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have z-index for center content', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerContent = container.querySelector('.z-\\[2\\]')
|
||||
expect(centerContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Line Positioning Tests
|
||||
// ================================
|
||||
describe('Line Positioning', () => {
|
||||
it('should position Line components correctly around icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Right line
|
||||
const rightLine = container.querySelector('.right-\\[-1px\\]')
|
||||
expect(rightLine).toBeInTheDocument()
|
||||
|
||||
// Left line
|
||||
const leftLine = container.querySelector('.left-\\[-1px\\]')
|
||||
expect(leftLine).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rotated Line components for top and bottom', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const rotatedLines = container.querySelectorAll('.rotate-90')
|
||||
expect(rotatedLines.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Combined Props Tests
|
||||
// ================================
|
||||
describe('Combined Props', () => {
|
||||
it('should handle all props together', () => {
|
||||
const { container } = render(
|
||||
<Empty
|
||||
text="Custom message"
|
||||
lightCard
|
||||
className="custom-wrapper"
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(container.querySelector('.custom-wrapper')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with lightCard false and custom text', () => {
|
||||
const { container } = render(
|
||||
<Empty text="No results" lightCard={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className with lightCard prop', () => {
|
||||
const { container } = render(
|
||||
<Empty className="test-class" lightCard />,
|
||||
)
|
||||
|
||||
const element = container.querySelector('.test-class')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// Verify light card styling is applied
|
||||
const lightCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(lightCards.length).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty props object', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only text prop', () => {
|
||||
render(<Empty text="Only text" />)
|
||||
|
||||
expect(screen.getByText('Only text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only lightCard prop', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only className prop', () => {
|
||||
const { container } = render(<Empty className="only-class" />)
|
||||
|
||||
expect(container.querySelector('.only-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only locale prop', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with unicode characters', () => {
|
||||
render(<Empty text="没有找到插件 🔍" />)
|
||||
|
||||
expect(screen.getByText('没有找到插件 🔍')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with HTML entities', () => {
|
||||
render(<Empty text="No plugins & no results" />)
|
||||
|
||||
expect(screen.getByText('No plugins & no results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle whitespace-only text', () => {
|
||||
const { container } = render(<Empty text=" " />)
|
||||
|
||||
// Whitespace-only text is truthy, so it should be rendered
|
||||
const textContainer = container.querySelector('.system-md-regular')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer?.textContent).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have text content visible', () => {
|
||||
render(<Empty text="No plugins available" />)
|
||||
|
||||
const textElement = screen.getByText('No plugins available')
|
||||
expect(textElement).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render text in proper container', () => {
|
||||
const { container } = render(<Empty text="Test message" />)
|
||||
|
||||
const textContainer = container.querySelector('.system-md-regular')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer).toHaveTextContent('Test message')
|
||||
})
|
||||
|
||||
it('should center text content', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const textContainer = container.querySelector('.text-center')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Overlay Tests
|
||||
// ================================
|
||||
describe('Overlay', () => {
|
||||
it('should render overlay with correct z-index', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.z-\\[1\\]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overlay with full coverage', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.inset-0')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overlay when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const overlay = container.querySelector('.inset-0.z-\\[1\\]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Empty and Line Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render Line components with correct theme in Empty', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// In light mode, should use light gradient ID
|
||||
const lightGradients = container.querySelectorAll('#paint0_linear_1989_74474')
|
||||
expect(lightGradients.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render Line components with dark theme in Empty', () => {
|
||||
mockTheme = 'dark'
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// In dark mode, should use dark gradient ID
|
||||
const darkGradients = container.querySelectorAll('#paint0_linear_6295_52176')
|
||||
expect(darkGradients.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should apply positioning classes to Line components', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Check for Line positioning classes
|
||||
expect(container.querySelector('.right-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(container.querySelector('.left-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.rotate-90').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render complete Empty component structure', () => {
|
||||
const { container } = render(<Empty text="Test" lightCard className="test" locale="en-US" />)
|
||||
|
||||
// Container
|
||||
expect(container.querySelector('.test')).toBeInTheDocument()
|
||||
|
||||
// Placeholder cards
|
||||
expect(container.querySelectorAll('.h-\\[144px\\]').length).toBe(16)
|
||||
|
||||
// Icon container
|
||||
expect(container.querySelector('.h-14.w-14')).toBeInTheDocument()
|
||||
|
||||
// Line components (4) + Group icon (1) = 5 SVGs total
|
||||
expect(container.querySelectorAll('svg').length).toBe(5)
|
||||
|
||||
// Text
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
|
||||
// No overlay for lightCard
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
3154
web/app/components/plugins/marketplace/index.spec.tsx
Normal file
3154
web/app/components/plugins/marketplace/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1702
web/app/components/plugins/marketplace/list/index.spec.tsx
Normal file
1702
web/app/components/plugins/marketplace/list/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1291
web/app/components/plugins/marketplace/search-box/index.spec.tsx
Normal file
1291
web/app/components/plugins/marketplace/search-box/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,742 @@
|
||||
import type { MarketplaceContextValue } from '../context'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SortDropdown from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.sortBy': 'Sort by',
|
||||
'plugin.marketplace.sortOption.mostPopular': 'Most Popular',
|
||||
'plugin.marketplace.sortOption.recentlyUpdated': 'Recently Updated',
|
||||
'plugin.marketplace.sortOption.newlyReleased': 'Newly Released',
|
||||
'plugin.marketplace.sortOption.firstReleased': 'First Released',
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: mockTranslation,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock marketplace context with controllable values
|
||||
let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const mockHandleSortChange = vi.fn()
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => {
|
||||
const contextValue = {
|
||||
sort: mockSort,
|
||||
handleSortChange: mockHandleSortChange,
|
||||
} as unknown as MarketplaceContextValue
|
||||
return selector(contextValue)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock portal component with controllable open state
|
||||
let mockPortalOpenState = false
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
mockPortalOpenState = open
|
||||
return (
|
||||
<div data-testid="portal-wrapper" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
// Match actual behavior: only render when portal is open
|
||||
if (!mockPortalOpenState)
|
||||
return null
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Factory Functions
|
||||
// ================================
|
||||
|
||||
type SortOption = {
|
||||
value: string
|
||||
order: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const createSortOptions = (): SortOption[] => [
|
||||
{ value: 'install_count', order: 'DESC', text: 'Most Popular' },
|
||||
{ value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' },
|
||||
{ value: 'created_at', order: 'DESC', text: 'Newly Released' },
|
||||
{ value: 'created_at', order: 'ASC', text: 'First Released' },
|
||||
]
|
||||
|
||||
// ================================
|
||||
// SortDropdown Component Tests
|
||||
// ================================
|
||||
describe('SortDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
mockPortalOpenState = false
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort by label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected option text', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow down icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary')
|
||||
expect(arrowIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element with correct styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Testing
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should accept locale prop', () => {
|
||||
render(<SortDropdown locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call useMixedTranslation with provided locale', () => {
|
||||
render(<SortDropdown locale="ja-JP" />)
|
||||
|
||||
// Translation function should be called for labels
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should render without locale prop (undefined)', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty string locale', () => {
|
||||
render(<SortDropdown locale="" />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed state', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should display correct selected option for install_count DESC', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for version_updated_at DESC', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at DESC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Newly Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at ASC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// After click, portal content should be visible
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger clicked again', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Open
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown on trigger click', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sort options when open', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
|
||||
expect(within(content).getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleSortChange when option clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Click on "Recently Updated"
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'version_updated_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Most Popular', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Newly Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for First Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow selecting currently selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should support userEvent for trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Check Icon Tests
|
||||
// ================================
|
||||
describe('Check Icon', () => {
|
||||
it('should show check icon for selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Check icon should be present in the dropdown
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show check icon only for matching sortBy AND sortOrder', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// "Newly Released" (created_at DESC) should have check icon
|
||||
// "First Released" (created_at ASC) should NOT have check icon
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should not show check icon for different sortOrder with same sortBy', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Only one check icon should be visible (for Newly Released, not First Released)
|
||||
const checkIcons = container.querySelectorAll('.text-text-accent')
|
||||
expect(checkIcons.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dropdown Options Structure Tests
|
||||
// ================================
|
||||
describe('Dropdown Options Structure', () => {
|
||||
const sortOptions = createSortOptions()
|
||||
|
||||
it('should render 4 sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it.each(sortOptions)('should render option: $text', ({ text }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText(text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options with unique keys', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// All options should be rendered (no key conflicts)
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render dropdown container with correct styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.firstChild as HTMLElement
|
||||
expect(container).toHaveClass('rounded-xl', 'shadow-lg')
|
||||
})
|
||||
|
||||
it('should render option items with hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.cursor-pointer')
|
||||
expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
// The component falls back to the first option (Most Popular) when sort values are invalid
|
||||
|
||||
it('should fallback to default option when sortBy is unknown', () => {
|
||||
mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Should fallback to first option "Most Popular"
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default option when sortBy is empty', () => {
|
||||
mockSort = { sortBy: '', sortOrder: 'DESC' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default option when sortOrder is unknown', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly when handleSortChange is a no-op', () => {
|
||||
mockHandleSortChange.mockImplementation(() => {})
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid toggle clicks', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Final state should be open (odd number of clicks)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple option selections', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
|
||||
// Click multiple options
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Context Integration Tests
|
||||
// ================================
|
||||
describe('Context Integration', () => {
|
||||
it('should read sort value from context', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call context handleSortChange on selection', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update display when context sort changes', () => {
|
||||
const { rerender } = render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
|
||||
// Simulate context change
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
rerender(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use selector pattern correctly', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Component should have called useMarketplaceContext with selector functions
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have cursor pointer on trigger', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have cursor pointer on options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have visible focus indicators via hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')
|
||||
expect(option).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Tests
|
||||
// ================================
|
||||
describe('Translations', () => {
|
||||
it('should call translation for sortBy label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should call translation for all sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.mostPopular', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<SortDropdown locale="pt-BR" />)
|
||||
|
||||
// Verify component renders with locale
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Component Integration Tests
|
||||
// ================================
|
||||
describe('Portal Component Integration', () => {
|
||||
it('should pass open state to PortalToFollowElem', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(wrapper).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should render trigger content inside PortalToFollowElemTrigger', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options inside PortalToFollowElemContent', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Visual Style Tests
|
||||
// ================================
|
||||
describe('Visual Styles', () => {
|
||||
it('should apply correct trigger container styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg')
|
||||
expect(triggerDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply secondary text color to sort by label', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const label = container.querySelector('.text-text-secondary')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('Sort by')
|
||||
})
|
||||
|
||||
it('should apply primary text color to selected option', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const selected = container.querySelector('.text-text-primary.system-sm-medium')
|
||||
expect(selected).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply tertiary text color to arrow icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrow = container.querySelector('.text-text-tertiary')
|
||||
expect(arrow).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply accent text color to check icon when option selected', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply blur backdrop to dropdown container', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.querySelector('.backdrop-blur-sm')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// All Sort Options Click Tests
|
||||
// ================================
|
||||
describe('All Sort Options Click Handlers', () => {
|
||||
const testCases = [
|
||||
{ text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' },
|
||||
{ text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' },
|
||||
{ text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' },
|
||||
{ text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' },
|
||||
]
|
||||
|
||||
it.each(testCases)(
|
||||
'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"',
|
||||
({ text, sortBy, sortOrder }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText(text))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder })
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -44,7 +44,7 @@ const SortDropdown = ({
|
||||
const sort = useMarketplaceContext(v => v.sort)
|
||||
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder)!
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
|
||||
@ -225,6 +225,7 @@ const AddOAuthButton = ({
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
data-testid="oauth-settings-button"
|
||||
className={cn(
|
||||
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
|
||||
buttonRightClassName,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
786
web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
Normal file
786
web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
Normal file
@ -0,0 +1,786 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginPayload } from '../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
import Authorize from './index'
|
||||
|
||||
// Create a wrapper with QueryClientProvider for real component testing
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock API hooks - only mock network-related hooks
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useGetPluginOAuthUrlHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }),
|
||||
}),
|
||||
useGetPluginOAuthClientSchemaHook: () => ({
|
||||
data: mockGetPluginOAuthClientSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSetPluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useDeletePluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useInvalidPluginOAuthClientSchemaHook: () => vi.fn(),
|
||||
useAddPluginCredentialHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useUpdatePluginCredentialHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useGetPluginCredentialSchemaHook: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock openOAuthPopup - window operations
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock service/use-triggers - API service
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({
|
||||
data: { options: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useTriggerPluginDynamicOptionsInfo: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Factory function for creating test PluginPayload
|
||||
const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Authorize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetPluginOAuthClientSchema.mockReturnValue({
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// No buttons should be rendered
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
// Container should only have wrapper element
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// OAuth button should exist (either configured or setup button)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only API Key button when canApiKey is true and canOAuth is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both OAuth and API Key buttons when both are true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render divider when showDivider is true and both buttons are shown', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDivider is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when only one button type is shown', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider by default (showDivider defaults to true)', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props Testing', () => {
|
||||
describe('theme prop', () => {
|
||||
it('should render buttons with secondary theme variant when theme is secondary', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
theme="secondary"
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.className).toContain('btn-secondary')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled prop', () => {
|
||||
it('should disable OAuth button when disabled is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when disabled is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable buttons when disabled is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('notAllowCustomCredential prop', () => {
|
||||
it('should disable OAuth button when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add opacity class when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const wrappers = container.querySelectorAll('.opacity-50')
|
||||
expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Button Text Variations ====================
|
||||
describe('Button Text Variations', () => {
|
||||
it('should show correct OAuth text based on canApiKey', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
// When canApiKey is false, should show "useOAuthAuth"
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('plugin.auth')
|
||||
|
||||
// When canApiKey is true, button text changes
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization Dependencies ====================
|
||||
describe('Memoization and Re-rendering', () => {
|
||||
it('should maintain stable props across re-renders with same dependencies', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialButtonCount = screen.getAllByRole('button').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(initialButtonCount)
|
||||
})
|
||||
|
||||
it('should update when canApiKey changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update when canOAuth changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update button variant when theme changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttonPrimary = screen.getByRole('button')
|
||||
// Primary theme with canOAuth=false should have primary variant
|
||||
expect(buttonPrimary.className).toContain('btn-primary')
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
theme="secondary"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined pluginPayload properties gracefully', () => {
|
||||
const pluginPayload: PluginPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
providerType: undefined,
|
||||
detail: undefined,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle all auth categories', () => {
|
||||
const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const pluginPayload = createPluginPayload({ category })
|
||||
|
||||
const { unmount } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty string provider', () => {
|
||||
const pluginPayload = createPluginPayload({ provider: '' })
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle both disabled and notAllowCustomCredential together', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Memoization ====================
|
||||
describe('Component Memoization', () => {
|
||||
it('should be a memoized component (exported with memo)', async () => {
|
||||
const AuthorizeDefault = (await import('./index')).default
|
||||
expect(AuthorizeDefault).toBeDefined()
|
||||
// memo wrapped components are React elements with $$typeof
|
||||
expect(typeof AuthorizeDefault).toBe('object')
|
||||
})
|
||||
|
||||
it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialOpacityElements = container.querySelectorAll('.opacity-50').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
|
||||
})
|
||||
|
||||
it('should update wrapper when notAllowCustomCredential changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(0)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Integration with pluginPayload ====================
|
||||
describe('pluginPayload Integration', () => {
|
||||
it('should pass pluginPayload to OAuth button', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
provider: 'special-provider',
|
||||
category: AuthCategory.model,
|
||||
})
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass pluginPayload to API Key button', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
provider: 'another-provider',
|
||||
category: AuthCategory.datasource,
|
||||
})
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pluginPayload with detail property', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
detail: {
|
||||
plugin_id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
} as PluginPayload['detail'],
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Conditional Rendering Scenarios ====================
|
||||
describe('Conditional Rendering Scenarios', () => {
|
||||
it('should handle rapid prop changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={true} />)
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={false} />)
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={false} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should correctly toggle divider visibility based on button combinations', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
showDivider={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Accessibility ====================
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible button elements', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should indicate disabled state for accessibility', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
2035
web/app/components/plugins/plugin-auth/index.spec.tsx
Normal file
2035
web/app/components/plugins/plugin-auth/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,717 @@
|
||||
import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Import component after mocks
|
||||
import LLMParamsPanel from './llm-params-panel'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
// All vi.mock() calls are hoisted, so inline all mock data
|
||||
|
||||
// Mock useModelParameterRules hook
|
||||
const mockUseModelParameterRules = vi.fn()
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useModelParameterRules: (provider: string, modelId: string) => mockUseModelParameterRules(provider, modelId),
|
||||
}))
|
||||
|
||||
// Mock config constants with inline data
|
||||
vi.mock('@/config', () => ({
|
||||
TONE_LIST: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Creative',
|
||||
config: {
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
presence_penalty: 0.1,
|
||||
frequency_penalty: 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Balanced',
|
||||
config: {
|
||||
temperature: 0.5,
|
||||
top_p: 0.85,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Precise',
|
||||
config: {
|
||||
temperature: 0.2,
|
||||
top_p: 0.75,
|
||||
presence_penalty: 0.5,
|
||||
frequency_penalty: 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Custom',
|
||||
},
|
||||
],
|
||||
STOP_PARAMETER_RULE: {
|
||||
default: [],
|
||||
help: {
|
||||
en_US: 'Stop sequences help text',
|
||||
zh_Hans: '停止序列帮助文本',
|
||||
},
|
||||
label: {
|
||||
en_US: 'Stop sequences',
|
||||
zh_Hans: '停止序列',
|
||||
},
|
||||
name: 'stop',
|
||||
required: false,
|
||||
type: 'tag',
|
||||
tagPlaceholder: {
|
||||
en_US: 'Enter sequence and press Tab',
|
||||
zh_Hans: '输入序列并按 Tab 键',
|
||||
},
|
||||
},
|
||||
PROVIDER_WITH_PRESET_TONE: ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'],
|
||||
}))
|
||||
|
||||
// Mock PresetsParameter component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock ParameterItem component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item', () => ({
|
||||
default: ({ parameterRule, value, onChange, onSwitch, isInWorkflow }: {
|
||||
parameterRule: { name: string, label: { en_US: string }, default?: unknown }
|
||||
value: unknown
|
||||
onChange: (v: unknown) => void
|
||||
onSwitch: (checked: boolean, assignValue: unknown) => void
|
||||
isInWorkflow?: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid={`parameter-item-${parameterRule.name}`}
|
||||
data-value={JSON.stringify(value)}
|
||||
data-is-in-workflow={isInWorkflow}
|
||||
>
|
||||
<span>{parameterRule.label.en_US}</span>
|
||||
<button data-testid={`change-${parameterRule.name}`} onClick={() => onChange(0.5)}>Change</button>
|
||||
<button data-testid={`switch-on-${parameterRule.name}`} onClick={() => onSwitch(true, parameterRule.default)}>Switch On</button>
|
||||
<button data-testid={`switch-off-${parameterRule.name}`} onClick={() => onSwitch(false, parameterRule.default)}>Switch Off</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
/**
|
||||
* Factory function to create a ModelParameterRule with defaults
|
||||
*/
|
||||
const createParameterRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
|
||||
name: 'temperature',
|
||||
label: { en_US: 'Temperature', zh_Hans: '温度' },
|
||||
type: 'float',
|
||||
default: 0.7,
|
||||
min: 0,
|
||||
max: 2,
|
||||
precision: 2,
|
||||
required: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create default props
|
||||
*/
|
||||
const createDefaultProps = (overrides: Partial<{
|
||||
isAdvancedMode: boolean
|
||||
provider: string
|
||||
modelId: string
|
||||
completionParams: FormValue
|
||||
onCompletionParamsChange: (newParams: FormValue) => void
|
||||
}> = {}) => ({
|
||||
isAdvancedMode: false,
|
||||
provider: 'langgenius/openai/openai',
|
||||
modelId: 'gpt-4',
|
||||
completionParams: {},
|
||||
onCompletionParamsChange: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Setup mock for useModelParameterRules
|
||||
*/
|
||||
const setupModelParameterRulesMock = (config: {
|
||||
data?: ModelParameterRule[]
|
||||
isPending?: boolean
|
||||
} = {}) => {
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: config.data ? { data: config.data } : undefined,
|
||||
isPending: config.isPending ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('LLMParamsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when isPending is true', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ isPending: true })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert - Loading component uses aria-label instead of visible text
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters header', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render PresetsParameter for openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render PresetsParameter for azure_openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter for non-preset providers', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'anthropic/claude' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameter items when rules are available', () => {
|
||||
// Arrange
|
||||
const rules = [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p', label: { en_US: 'Top P', zh_Hans: 'Top P' } }),
|
||||
]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render parameter items when rules are empty', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('parameter-item-temperature')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should include stop parameter rule in advanced mode', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({ isAdvancedMode: true })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-stop')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not include stop parameter rule in non-advanced mode', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({ isAdvancedMode: false })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('parameter-item-stop')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass isInWorkflow=true to ParameterItem', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-is-in-workflow', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should call useModelParameterRules with provider and modelId', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(mockUseModelParameterRules).toHaveBeenCalledWith('test-provider', 'test-model')
|
||||
})
|
||||
|
||||
it('should pass completion params value to ParameterItem', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({
|
||||
completionParams: { temperature: 0.8 },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-value', '0.8')
|
||||
})
|
||||
|
||||
it('should handle undefined completion params value', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({
|
||||
completionParams: {},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert - when value is undefined, JSON.stringify returns undefined string
|
||||
expect(screen.getByTestId('parameter-item-temperature')).not.toHaveAttribute('data-value')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Event Handlers ====================
|
||||
describe('Event Handlers', () => {
|
||||
describe('handleSelectPresetParameter', () => {
|
||||
it('should apply Creative preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-creative'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
presence_penalty: 0.1,
|
||||
frequency_penalty: 0.1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply Balanced preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: {},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-balanced'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
temperature: 0.5,
|
||||
top_p: 0.85,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: 0.3,
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply Precise preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: {},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-precise'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
temperature: 0.2,
|
||||
top_p: 0.75,
|
||||
presence_penalty: 0.5,
|
||||
frequency_penalty: 0.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply empty config for Custom preset (spreads undefined)', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-custom'))
|
||||
|
||||
// Assert - Custom preset has no config, so only existing params are kept
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleParamChange', () => {
|
||||
it('should call onCompletionParamsChange with updated param', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('change-temperature'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('should override existing param value', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({
|
||||
onCompletionParamsChange,
|
||||
completionParams: { temperature: 0.9 },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('change-temperature'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
temperature: 0.5,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSwitch', () => {
|
||||
it('should add param when switch is turned on', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
const rules = [createParameterRule({ name: 'temperature', default: 0.7 })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('switch-on-temperature'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.7,
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove param when switch is turned off', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({
|
||||
onCompletionParamsChange,
|
||||
completionParams: { temperature: 0.8, other: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('switch-off-temperature'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
other: 'value',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization ====================
|
||||
describe('Memoization - parameterRules', () => {
|
||||
it('should return empty array when data is undefined', () => {
|
||||
// Arrange
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert - no parameter items should be rendered
|
||||
expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return empty array when data.data is undefined', () => {
|
||||
// Arrange
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: { data: undefined },
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use data.data when available', () => {
|
||||
// Arrange
|
||||
const rules = [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty completionParams', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({ completionParams: {} })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple parameter rules', () => {
|
||||
// Arrange
|
||||
const rules = [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'max_tokens', type: 'int' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-max_tokens')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-presence_penalty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use unique keys for parameter items based on modelId and name', () => {
|
||||
// Arrange
|
||||
const rules = [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({ modelId: 'gpt-4' })
|
||||
|
||||
// Act
|
||||
const { container } = render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert - verify both items are rendered (keys are internal but rendering proves uniqueness)
|
||||
const items = container.querySelectorAll('[data-testid^="parameter-item-"]')
|
||||
expect(items).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Re-render Behavior ====================
|
||||
describe('Re-render Behavior', () => {
|
||||
it('should update parameter items when rules change', () => {
|
||||
// Arrange
|
||||
const initialRules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: initialRules, isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<LLMParamsPanel {...props} />)
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('parameter-item-top_p')).not.toBeInTheDocument()
|
||||
|
||||
// Update mock
|
||||
const newRules = [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
]
|
||||
setupModelParameterRulesMock({ data: newRules, isPending: false })
|
||||
rerender(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading when transitioning from loaded to loading', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<LLMParamsPanel {...props} />)
|
||||
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
|
||||
|
||||
// Update to loading
|
||||
setupModelParameterRulesMock({ isPending: true })
|
||||
rerender(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert - Loading component uses role="status" with aria-label
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when isAdvancedMode changes', () => {
|
||||
// Arrange
|
||||
const rules = [createParameterRule({ name: 'temperature' })]
|
||||
setupModelParameterRulesMock({ data: rules, isPending: false })
|
||||
const props = createDefaultProps({ isAdvancedMode: false })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<LLMParamsPanel {...props} />)
|
||||
expect(screen.queryByTestId('parameter-item-stop')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<LLMParamsPanel {...props} isAdvancedMode={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('parameter-item-stop')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Type ====================
|
||||
describe('Component Type', () => {
|
||||
it('should be a functional component', () => {
|
||||
// Assert
|
||||
expect(typeof LLMParamsPanel).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept all required props', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act & Assert
|
||||
expect(() => render(<LLMParamsPanel {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,623 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Import component after mocks
|
||||
import TTSParamsPanel from './tts-params-panel'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
// All vi.mock() calls are hoisted, so inline all mock data
|
||||
|
||||
// Mock languages data with inline definition
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
languages: [
|
||||
{ value: 'en-US', name: 'English (United States)', supported: true },
|
||||
{ value: 'zh-Hans', name: '简体中文', supported: true },
|
||||
{ value: 'ja-JP', name: '日本語', supported: true },
|
||||
{ value: 'unsupported-lang', name: 'Unsupported Language', supported: false },
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock PortalSelect component
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
PortalSelect: ({
|
||||
value,
|
||||
items,
|
||||
onSelect,
|
||||
triggerClassName,
|
||||
popupClassName,
|
||||
popupInnerClassName,
|
||||
}: {
|
||||
value: string
|
||||
items: Array<{ value: string, name: string }>
|
||||
onSelect: (item: { value: string }) => void
|
||||
triggerClassName?: string
|
||||
popupClassName?: string
|
||||
popupInnerClassName?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="portal-select"
|
||||
data-value={value}
|
||||
data-trigger-class={triggerClassName}
|
||||
data-popup-class={popupClassName}
|
||||
data-popup-inner-class={popupInnerClassName}
|
||||
>
|
||||
<span data-testid="selected-value">{value}</span>
|
||||
<div data-testid="items-container">
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`select-item-${item.value}`}
|
||||
onClick={() => onSelect({ value: item.value })}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
/**
|
||||
* Factory function to create a voice item
|
||||
*/
|
||||
const createVoiceItem = (overrides: Partial<{ mode: string, name: string }> = {}) => ({
|
||||
mode: 'alloy',
|
||||
name: 'Alloy',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a currentModel with voices
|
||||
*/
|
||||
const createCurrentModel = (voices: Array<{ mode: string, name: string }> = []) => ({
|
||||
model_properties: {
|
||||
voices,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create default props
|
||||
*/
|
||||
const createDefaultProps = (overrides: Partial<{
|
||||
currentModel: { model_properties: { voices: Array<{ mode: string, name: string }> } } | null
|
||||
language: string
|
||||
voice: string
|
||||
onChange: (language: string, voice: string) => void
|
||||
}> = {}) => ({
|
||||
currentModel: createCurrentModel([
|
||||
createVoiceItem({ mode: 'alloy', name: 'Alloy' }),
|
||||
createVoiceItem({ mode: 'echo', name: 'Echo' }),
|
||||
createVoiceItem({ mode: 'fable', name: 'Fable' }),
|
||||
]),
|
||||
language: 'en-US',
|
||||
voice: 'alloy',
|
||||
onChange: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('TTSParamsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render language label', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.voice.voiceSettings.language')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice label', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two PortalSelect components', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render language select with correct value', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ language: 'zh-Hans' })
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
|
||||
})
|
||||
|
||||
it('should render voice select with correct value', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ voice: 'echo' })
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[1]).toHaveAttribute('data-value', 'echo')
|
||||
})
|
||||
|
||||
it('should only show supported languages in language select', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-en-US')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-zh-Hans')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-ja-JP')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('select-item-unsupported-lang')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice items from currentModel', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-echo')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-fable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should apply trigger className to PortalSelect', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
|
||||
expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
|
||||
})
|
||||
|
||||
it('should apply popup className to PortalSelect', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
|
||||
expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
|
||||
})
|
||||
|
||||
it('should apply popup inner className to PortalSelect', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
|
||||
expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Event Handlers ====================
|
||||
describe('Event Handlers', () => {
|
||||
describe('setLanguage', () => {
|
||||
it('should call onChange with new language and current voice', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({
|
||||
onChange,
|
||||
language: 'en-US',
|
||||
voice: 'alloy',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'alloy')
|
||||
})
|
||||
|
||||
it('should call onChange with different languages', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({
|
||||
onChange,
|
||||
language: 'en-US',
|
||||
voice: 'echo',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-ja-JP'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
|
||||
})
|
||||
|
||||
it('should preserve voice when changing language', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({
|
||||
onChange,
|
||||
language: 'en-US',
|
||||
voice: 'fable',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setVoice', () => {
|
||||
it('should call onChange with current language and new voice', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({
|
||||
onChange,
|
||||
language: 'en-US',
|
||||
voice: 'alloy',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-echo'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('en-US', 'echo')
|
||||
})
|
||||
|
||||
it('should call onChange with different voices', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({
|
||||
onChange,
|
||||
language: 'zh-Hans',
|
||||
voice: 'alloy',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-fable'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
|
||||
})
|
||||
|
||||
it('should preserve language when changing voice', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({
|
||||
onChange,
|
||||
language: 'ja-JP',
|
||||
voice: 'alloy',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-echo'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization ====================
|
||||
describe('Memoization - voiceList', () => {
|
||||
it('should return empty array when currentModel is null', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ currentModel: null })
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert - no voice items should be rendered
|
||||
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('select-item-echo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return empty array when currentModel is undefined', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
currentModel: undefined,
|
||||
language: 'en-US',
|
||||
voice: 'alloy',
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should map voices with mode as value', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({
|
||||
currentModel: createCurrentModel([
|
||||
{ mode: 'voice-1', name: 'Voice One' },
|
||||
{ mode: 'voice-2', name: 'Voice Two' },
|
||||
]),
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-voice-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-voice-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle currentModel with empty voices array', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({
|
||||
currentModel: createCurrentModel([]),
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert - no voice items (except language items)
|
||||
const voiceSelects = screen.getAllByTestId('portal-select')
|
||||
// Second select is voice select, should have no voice items in items-container
|
||||
const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
|
||||
expect(voiceItemsContainer?.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle currentModel with single voice', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({
|
||||
currentModel: createCurrentModel([
|
||||
{ mode: 'single-voice', name: 'Single Voice' },
|
||||
]),
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-single-voice')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty language value', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ language: '' })
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-value', '')
|
||||
})
|
||||
|
||||
it('should handle empty voice value', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ voice: '' })
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[1]).toHaveAttribute('data-value', '')
|
||||
})
|
||||
|
||||
it('should handle many voices', () => {
|
||||
// Arrange
|
||||
const manyVoices = Array.from({ length: 20 }, (_, i) => ({
|
||||
mode: `voice-${i}`,
|
||||
name: `Voice ${i}`,
|
||||
}))
|
||||
const props = createDefaultProps({
|
||||
currentModel: createCurrentModel(manyVoices),
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-voice-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-voice-19')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle voice with special characters in mode', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({
|
||||
currentModel: createCurrentModel([
|
||||
{ mode: 'voice-with_special.chars', name: 'Special Voice' },
|
||||
]),
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-voice-with_special.chars')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle onChange not being called multiple times', () => {
|
||||
// Arrange
|
||||
const onChange = vi.fn()
|
||||
const props = createDefaultProps({ onChange })
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('select-item-echo'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Re-render Behavior ====================
|
||||
describe('Re-render Behavior', () => {
|
||||
it('should update when language prop changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ language: 'en-US' })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<TTSParamsPanel {...props} />)
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-value', 'en-US')
|
||||
|
||||
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
|
||||
|
||||
// Assert
|
||||
const updatedSelects = screen.getAllByTestId('portal-select')
|
||||
expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
|
||||
})
|
||||
|
||||
it('should update when voice prop changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ voice: 'alloy' })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<TTSParamsPanel {...props} />)
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[1]).toHaveAttribute('data-value', 'alloy')
|
||||
|
||||
rerender(<TTSParamsPanel {...props} voice="echo" />)
|
||||
|
||||
// Assert
|
||||
const updatedSelects = screen.getAllByTestId('portal-select')
|
||||
expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
|
||||
})
|
||||
|
||||
it('should update voice list when currentModel changes', () => {
|
||||
// Arrange
|
||||
const initialModel = createCurrentModel([
|
||||
{ mode: 'alloy', name: 'Alloy' },
|
||||
])
|
||||
const props = createDefaultProps({ currentModel: initialModel })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<TTSParamsPanel {...props} />)
|
||||
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('select-item-nova')).not.toBeInTheDocument()
|
||||
|
||||
const newModel = createCurrentModel([
|
||||
{ mode: 'alloy', name: 'Alloy' },
|
||||
{ mode: 'nova', name: 'Nova' },
|
||||
])
|
||||
rerender(<TTSParamsPanel {...props} currentModel={newModel} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-nova')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle currentModel becoming null', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<TTSParamsPanel {...props} />)
|
||||
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
|
||||
|
||||
rerender(<TTSParamsPanel {...props} currentModel={null} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Type ====================
|
||||
describe('Component Type', () => {
|
||||
it('should be a functional component', () => {
|
||||
// Assert
|
||||
expect(typeof TTSParamsPanel).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept all required props', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act & Assert
|
||||
expect(() => render(<TTSParamsPanel {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Accessibility ====================
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper label structure for language select', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const languageLabel = screen.getByText('appDebug.voice.voiceSettings.language')
|
||||
expect(languageLabel).toHaveClass('system-sm-semibold')
|
||||
})
|
||||
|
||||
it('should have proper label structure for voice select', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const voiceLabel = screen.getByText('appDebug.voice.voiceSettings.voice')
|
||||
expect(voiceLabel).toHaveClass('system-sm-semibold')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DeleteConfirm } from './delete-confirm'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDelete.mockImplementation((_id: string, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteConfirm', () => {
|
||||
it('should prevent deletion when workflows in use and input mismatch', () => {
|
||||
render(
|
||||
<DeleteConfirm
|
||||
isShow
|
||||
currentId="sub-1"
|
||||
currentName="Subscription One"
|
||||
workflowsInUse={2}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled()
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should allow deletion after matching input name', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DeleteConfirm
|
||||
isShow
|
||||
currentId="sub-1"
|
||||
currentName="Subscription One"
|
||||
workflowsInUse={1}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputPlaceholder/),
|
||||
{ target: { value: 'Subscription One' } },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object))
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show error toast when delete fails', () => {
|
||||
mockDelete.mockImplementation((_id: string, options?: { onError?: (error: Error) => void }) => {
|
||||
options?.onError?.(new Error('network error'))
|
||||
})
|
||||
|
||||
render(
|
||||
<DeleteConfirm
|
||||
isShow
|
||||
currentId="sub-1"
|
||||
currentName="Subscription One"
|
||||
workflowsInUse={0}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'network error' }))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { ApiKeyEditModal } from './apikey-edit-modal'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockVerify = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: {
|
||||
trigger: {
|
||||
subscription_constructor: {
|
||||
parameters: [],
|
||||
credentials_schema: [
|
||||
{
|
||||
name: 'api_key',
|
||||
type: 'secret',
|
||||
label: 'API Key',
|
||||
required: false,
|
||||
default: 'token',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }),
|
||||
useVerifyTriggerSubscription: () => ({ mutate: mockVerify, isPending: false }),
|
||||
useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
useToastContext: () => ({
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockVerify.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiKeyEditModal', () => {
|
||||
it('should render verify step with encrypted hint and allow cancel', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ApiKeyEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'pluginTrigger.modal.common.back' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(content => content.includes('common.provider.encrypted.front'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,98 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { ManualEditModal } from './manual-edit-modal'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: { trigger: { subscription_schema: [] } },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }),
|
||||
useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
useToastContext: () => ({
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.Unauthorized,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ManualEditModal', () => {
|
||||
it('should render title and allow cancel', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ManualEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should submit update with default values', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ManualEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscriptionId: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
properties: undefined,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,98 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { OAuthEditModal } from './oauth-edit-modal'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: { trigger: { subscription_constructor: { parameters: [] } } },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }),
|
||||
useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
useToastContext: () => ({
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthEditModal', () => {
|
||||
it('should render title and allow cancel', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<OAuthEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should submit update with default values', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<OAuthEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscriptionId: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
parameters: undefined,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,213 @@
|
||||
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionList } from './index'
|
||||
import { SubscriptionListMode } from './types'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
let mockSubscriptionListError: Error | null = null
|
||||
let mockSubscriptionListState: {
|
||||
isLoading: boolean
|
||||
refetch: () => void
|
||||
subscriptions?: TriggerSubscription[]
|
||||
}
|
||||
|
||||
let mockPluginDetail: PluginDetail | undefined
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => {
|
||||
if (mockSubscriptionListError)
|
||||
throw mockSubscriptionListError
|
||||
return mockSubscriptionListState
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: (selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined) =>
|
||||
selector({ detail: mockPluginDetail }),
|
||||
}))
|
||||
|
||||
const mockInitiateOAuth = vi.fn()
|
||||
const mockDeleteSubscription = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: mockInitiateOAuth }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDeleteSubscription, isPending: false }),
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'plugin-detail-1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'plugin-id',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
declaration: {} as PluginDeclaration,
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'plugin-uid',
|
||||
source: 'marketplace' as PluginDetail['source'],
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRefetch.mockReset()
|
||||
mockSubscriptionListError = null
|
||||
mockPluginDetail = undefined
|
||||
mockSubscriptionListState = {
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
subscriptions: [createSubscription()],
|
||||
}
|
||||
})
|
||||
|
||||
describe('SubscriptionList', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render list view by default', () => {
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when subscriptions are loading', () => {
|
||||
mockSubscriptionListState = {
|
||||
...mockSubscriptionListState,
|
||||
isLoading: true,
|
||||
}
|
||||
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render list view with plugin detail provided', () => {
|
||||
const pluginDetail = createPluginDetail()
|
||||
|
||||
render(<SubscriptionList pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without list entries when subscriptions are empty', () => {
|
||||
mockSubscriptionListState = {
|
||||
...mockSubscriptionListState,
|
||||
subscriptions: [],
|
||||
}
|
||||
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render selector view when mode is selector', () => {
|
||||
render(<SubscriptionList mode={SubscriptionListMode.SELECTOR} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should highlight the selected subscription when selectedId is provided', () => {
|
||||
render(
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
selectedId="sub-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectedButton = screen.getByRole('button', { name: 'Subscription One' })
|
||||
const selectedRow = selectedButton.closest('div')
|
||||
|
||||
expect(selectedRow).toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect with refetch callback when selecting a subscription', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
const [selectedSubscription, callback] = onSelect.mock.calls[0]
|
||||
expect(selectedSubscription).toMatchObject({ id: 'sub-1', name: 'Subscription One' })
|
||||
expect(typeof callback).toBe('function')
|
||||
|
||||
callback?.()
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onSelect is undefined', () => {
|
||||
render(<SubscriptionList mode={SubscriptionListMode.SELECTOR} />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should open delete confirm without triggering selection', () => {
|
||||
const onSelect = vi.fn()
|
||||
const { container } = render(
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
expect(deleteButton).toBeTruthy()
|
||||
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render error boundary fallback when an error occurs', () => {
|
||||
mockSubscriptionListError = new Error('boom')
|
||||
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,63 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionListView } from './list-view'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ subscriptions: mockSubscriptions }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({ detail: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }),
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubscriptions = []
|
||||
})
|
||||
|
||||
describe('SubscriptionListView', () => {
|
||||
it('should render subscription count and list when data exists', () => {
|
||||
mockSubscriptions = [createSubscription()]
|
||||
|
||||
render(<SubscriptionListView />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should omit count and list when subscriptions are empty', () => {
|
||||
render(<SubscriptionListView />)
|
||||
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply top border when showTopBorder is true', () => {
|
||||
const { container } = render(<SubscriptionListView showTopBorder />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('border-t')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,179 @@
|
||||
import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LogViewer from './log-viewer'
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
const mockWriteText = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToastNotify(args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value }: { value: unknown }) => (
|
||||
<div data-testid="code-editor">{JSON.stringify(value)}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createLog = (overrides: Partial<TriggerLogEntity> = {}): TriggerLogEntity => ({
|
||||
id: 'log-1',
|
||||
endpoint: 'https://example.com',
|
||||
created_at: '2024-01-01T12:34:56Z',
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
headers: {
|
||||
'Host': 'example.com',
|
||||
'User-Agent': 'vitest',
|
||||
'Content-Length': '0',
|
||||
'Accept': '*/*',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Forwarded-Host': 'example.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Github-Delivery': '1',
|
||||
'X-Github-Event': 'push',
|
||||
'X-Github-Hook-Id': '1',
|
||||
'X-Github-Hook-Installation-Target-Id': '1',
|
||||
'X-Github-Hook-Installation-Target-Type': 'repo',
|
||||
'Accept-Encoding': 'gzip',
|
||||
},
|
||||
data: 'payload=%7B%22foo%22%3A%22bar%22%7D',
|
||||
},
|
||||
response: {
|
||||
status_code: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': '2',
|
||||
},
|
||||
data: '{"ok":true}',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('LogViewer', () => {
|
||||
it('should render nothing when logs are empty', () => {
|
||||
const { container } = render(<LogViewer logs={[]} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render collapsed log entries', () => {
|
||||
render(<LogViewer logs={[createLog()]} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.modal\.manual\.logs\.request/)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand and render request/response payloads', () => {
|
||||
render(<LogViewer logs={[createLog()]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
const editors = screen.getAllByTestId('code-editor')
|
||||
expect(editors.length).toBe(2)
|
||||
expect(editors[0]).toHaveTextContent('"foo":"bar"')
|
||||
})
|
||||
|
||||
it('should collapse expanded content when clicked again', () => {
|
||||
render(<LogViewer logs={[createLog()]} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByTestId('code-editor').length).toBe(2)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error styling when response is an error', () => {
|
||||
render(<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
|
||||
const wrapper = trigger.parentElement as HTMLElement
|
||||
|
||||
expect(wrapper).toHaveClass('border-state-destructive-border')
|
||||
})
|
||||
|
||||
it('should render raw response text and allow copying', () => {
|
||||
const rawLog = {
|
||||
...createLog(),
|
||||
response: 'plain response',
|
||||
} as unknown as TriggerLogEntity
|
||||
|
||||
render(<LogViewer logs={[rawLog]} />)
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
expect(screen.getByText('plain response')).toBeInTheDocument()
|
||||
|
||||
const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton)
|
||||
expect(copyButton).toBeDefined()
|
||||
if (copyButton)
|
||||
fireEvent.click(copyButton)
|
||||
expect(mockWriteText).toHaveBeenCalledWith('plain response')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
})
|
||||
|
||||
it('should parse request data when it is raw JSON', () => {
|
||||
const log = createLog({ request: { ...createLog().request, data: '{\"hello\":1}' } })
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('"hello":1')
|
||||
})
|
||||
|
||||
it('should fallback to raw payload when decoding fails', () => {
|
||||
const log = createLog({ request: { ...createLog().request, data: 'payload=%E0%A4%A' } })
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('payload=%E0%A4%A')
|
||||
})
|
||||
|
||||
it('should keep request data string when JSON parsing fails', () => {
|
||||
const log = createLog({ request: { ...createLog().request, data: '{invalid}' } })
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('{invalid}')
|
||||
})
|
||||
|
||||
it('should render multiple log entries with distinct indices', () => {
|
||||
const first = createLog({ id: 'log-1' })
|
||||
const second = createLog({ id: 'log-2', created_at: '2024-01-01T12:35:00Z' })
|
||||
|
||||
render(<LogViewer logs={[first, second]} />)
|
||||
|
||||
expect(screen.getByText(/#1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/#2/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use index-based key when id is missing', () => {
|
||||
const log = { ...createLog(), id: '' }
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
expect(screen.getByText(/#1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorEntry } from './selector-entry'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({
|
||||
subscriptions: mockSubscriptions,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({ detail: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSubscriptions = [createSubscription()]
|
||||
})
|
||||
|
||||
describe('SubscriptionSelectorEntry', () => {
|
||||
it('should render empty state label when no selection and closed', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('pluginTrigger.subscription.noSubscriptionSelected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder when open without selection', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByText('pluginTrigger.subscription.selectPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show selected subscription name when id matches', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId="sub-1" onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show removed label when selected subscription is missing', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId="missing" onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('pluginTrigger.subscription.subscriptionRemoved')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect and close the list after selection', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function))
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,139 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorView } from './selector-view'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
const mockRefetch = vi.fn()
|
||||
const mockDelete = vi.fn((_: string, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({ detail: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSubscriptions = [createSubscription()]
|
||||
})
|
||||
|
||||
describe('SubscriptionSelectorView', () => {
|
||||
it('should render subscription list when data exists', () => {
|
||||
render(<SubscriptionSelectorView />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect when a subscription is clicked', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(<SubscriptionSelectorView onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }))
|
||||
})
|
||||
|
||||
it('should handle missing onSelect without crashing', () => {
|
||||
render(<SubscriptionSelectorView />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should highlight selected subscription row when selectedId matches', () => {
|
||||
render(<SubscriptionSelectorView selectedId="sub-1" />)
|
||||
|
||||
const selectedRow = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
|
||||
expect(selectedRow).toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should not highlight row when selectedId does not match', () => {
|
||||
render(<SubscriptionSelectorView selectedId="other-id" />)
|
||||
|
||||
const row = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
|
||||
expect(row).not.toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should omit header when there are no subscriptions', () => {
|
||||
mockSubscriptions = []
|
||||
|
||||
render(<SubscriptionSelectorView />)
|
||||
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show delete confirm when delete action is clicked', () => {
|
||||
const { container } = render(<SubscriptionSelectorView />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
expect(deleteButton).toBeTruthy()
|
||||
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request selection reset after confirming delete', () => {
|
||||
const onSelect = vi.fn()
|
||||
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object))
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: '', name: '' })
|
||||
})
|
||||
|
||||
it('should close delete confirm without selection reset on cancel', () => {
|
||||
const onSelect = vi.fn()
|
||||
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ }))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import SubscriptionCard from './subscription-card'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: { trigger: { subscription_constructor: { parameters: [], credentials_schema: [] } } },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useVerifyTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('SubscriptionCard', () => {
|
||||
it('should render subscription name and endpoint', () => {
|
||||
render(<SubscriptionCard data={createSubscription()} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render used-by text when workflows are present', () => {
|
||||
render(<SubscriptionCard data={createSubscription({ workflows_in_use: 2 })} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.usedByNum/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open delete confirmation when delete action is clicked', () => {
|
||||
const { container } = render(<SubscriptionCard data={createSubscription()} />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
expect(deleteButton).toBeTruthy()
|
||||
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open edit modal when edit action is clicked', () => {
|
||||
const { container } = render(<SubscriptionCard data={createSubscription()} />)
|
||||
|
||||
const actionButtons = container.querySelectorAll('button')
|
||||
const editButton = actionButtons[0]
|
||||
|
||||
fireEvent.click(editButton)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
import type { SimpleDetail } from '../store'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSubscriptionList } from './use-subscription-list'
|
||||
|
||||
let mockDetail: SimpleDetail | undefined
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
const mockTriggerSubscriptions = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerSubscriptions: (...args: unknown[]) => mockTriggerSubscriptions(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
|
||||
selector({ detail: mockDetail }),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDetail = undefined
|
||||
mockTriggerSubscriptions.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSubscriptionList', () => {
|
||||
it('should request subscriptions with provider from store', () => {
|
||||
mockDetail = {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'test-provider',
|
||||
declaration: {},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useSubscriptionList())
|
||||
|
||||
expect(mockTriggerSubscriptions).toHaveBeenCalledWith('test-provider')
|
||||
expect(result.current.detail).toEqual(mockDetail)
|
||||
})
|
||||
|
||||
it('should request subscriptions with empty provider when detail is missing', () => {
|
||||
const { result } = renderHook(() => useSubscriptionList())
|
||||
|
||||
expect(mockTriggerSubscriptions).toHaveBeenCalledWith('')
|
||||
expect(result.current.detail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return data from trigger subscription hook', () => {
|
||||
mockTriggerSubscriptions.mockReturnValue({
|
||||
data: [{ id: 'sub-1' }],
|
||||
isLoading: true,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSubscriptionList())
|
||||
|
||||
expect(result.current.subscriptions).toEqual([{ id: 'sub-1' }])
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.refetch).toBe(mockRefetch)
|
||||
})
|
||||
})
|
||||
937
web/app/components/plugins/plugin-item/action.spec.tsx
Normal file
937
web/app/components/plugins/plugin-item/action.spec.tsx
Normal file
@ -0,0 +1,937 @@
|
||||
import type { MetaData, PluginCategoryEnum } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import { PluginSource } from '../types'
|
||||
import Action from './action'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Use vi.hoisted to define mock functions that can be referenced in vi.mock
|
||||
const {
|
||||
mockUninstallPlugin,
|
||||
mockFetchReleases,
|
||||
mockCheckForUpdates,
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockInvalidateInstalledPluginList,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUninstallPlugin: vi.fn(),
|
||||
mockFetchReleases: vi.fn(),
|
||||
mockCheckForUpdates: vi.fn(),
|
||||
mockSetShowUpdatePluginModal: vi.fn(),
|
||||
mockInvalidateInstalledPluginList: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock uninstall plugin service
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: (id: string) => mockUninstallPlugin(id),
|
||||
}))
|
||||
|
||||
// Mock GitHub releases hook
|
||||
vi.mock('../install-plugin/hooks', () => ({
|
||||
useGitHubReleases: () => ({
|
||||
fetchReleases: mockFetchReleases,
|
||||
checkForUpdates: mockCheckForUpdates,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock modal context
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock invalidate installed plugin list
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
}))
|
||||
|
||||
// Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem)
|
||||
vi.mock('../plugin-page/plugin-info', () => ({
|
||||
default: ({ repository, release, packageName, onHide }: {
|
||||
repository: string
|
||||
release: string
|
||||
packageName: string
|
||||
onHide: () => void
|
||||
}) => (
|
||||
<div data-testid="plugin-info-modal" data-repo={repository} data-release={release} data-package={packageName}>
|
||||
<button data-testid="close-plugin-info" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup
|
||||
// Simplified mock that just renders children with tooltip content accessible
|
||||
vi.mock('../../base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-popup-content={popupContent}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Confirm - uses createPortal which has issues in test environment
|
||||
vi.mock('../../base/confirm', () => ({
|
||||
default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: {
|
||||
isShow: boolean
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
isDisabled: boolean
|
||||
}) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-modal" data-loading={isLoading} data-disabled={isDisabled}>
|
||||
<div data-testid="confirm-title">{title}</div>
|
||||
<div data-testid="confirm-content">{content}</div>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isDisabled}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
type ActionProps = {
|
||||
author: string
|
||||
installationId: string
|
||||
pluginUniqueIdentifier: string
|
||||
pluginName: string
|
||||
category: PluginCategoryEnum
|
||||
usedInApps: number
|
||||
isShowFetchNewVersion: boolean
|
||||
isShowInfo: boolean
|
||||
isShowDelete: boolean
|
||||
onDelete: () => void
|
||||
meta?: MetaData
|
||||
}
|
||||
|
||||
const createActionProps = (overrides: Partial<ActionProps> = {}): ActionProps => ({
|
||||
author: 'test-author',
|
||||
installationId: 'install-123',
|
||||
pluginUniqueIdentifier: 'test-author/test-plugin@1.0.0',
|
||||
pluginName: 'test-plugin',
|
||||
category: 'tool' as PluginCategoryEnum,
|
||||
usedInApps: 5,
|
||||
isShowFetchNewVersion: false,
|
||||
isShowInfo: false,
|
||||
isShowDelete: true,
|
||||
onDelete: vi.fn(),
|
||||
meta: {
|
||||
repo: 'test-author/test-plugin',
|
||||
version: '1.0.0',
|
||||
package: 'test-plugin.difypkg',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
// Helper to find action buttons (real ActionButton component uses type="button")
|
||||
const getActionButtons = () => screen.getAllByRole('button')
|
||||
const queryActionButtons = () => screen.queryAllByRole('button')
|
||||
|
||||
describe('Action Component', () => {
|
||||
// Spy on Toast.notify - real component but we track calls
|
||||
let toastNotifySpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Spy on Toast.notify and mock implementation to avoid DOM side effects
|
||||
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
mockFetchReleases.mockResolvedValue([])
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: false,
|
||||
toastProps: { type: 'info', message: 'Up to date' },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
toastNotifySpy.mockRestore()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render delete button when isShowDelete is true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render fetch new version button when isShowFetchNewVersion is true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowInfo: false,
|
||||
isShowDelete: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render info button when isShowInfo is true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: false,
|
||||
isShowInfo: true,
|
||||
isShowDelete: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render all buttons when all flags are true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowInfo: true,
|
||||
isShowDelete: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render no buttons when all flags are false', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: false,
|
||||
isShowInfo: false,
|
||||
isShowDelete: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(queryActionButtons()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render tooltips for each button', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowInfo: true,
|
||||
isShowDelete: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
const tooltips = screen.getAllByTestId('tooltip')
|
||||
expect(tooltips).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Delete Functionality Tests ====================
|
||||
describe('Delete Functionality', () => {
|
||||
it('should show delete confirm modal when delete button is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete')
|
||||
})
|
||||
|
||||
it('should display plugin name in delete confirm content', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
pluginName: 'my-awesome-plugin',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide confirm modal when cancel is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call uninstallPlugin when confirm is clicked', async () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
installationId: 'install-456',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-456')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onDelete callback after successful uninstall', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const onDelete = vi.fn()
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(onDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onDelete if uninstall fails', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: false })
|
||||
const onDelete = vi.fn()
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalled()
|
||||
})
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle uninstall error gracefully', async () => {
|
||||
// Arrange
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockUninstallPlugin.mockRejectedValue(new Error('Network error'))
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith('uninstallPlugin error', expect.any(Error))
|
||||
})
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('should show loading state during deletion', async () => {
|
||||
// Arrange
|
||||
let resolveUninstall: (value: { success: boolean }) => void
|
||||
mockUninstallPlugin.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveUninstall = resolve
|
||||
}),
|
||||
)
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert - Loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
|
||||
})
|
||||
|
||||
// Resolve and check modal closes
|
||||
resolveUninstall!({ success: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Plugin Info Tests ====================
|
||||
describe('Plugin Info', () => {
|
||||
it('should show plugin info modal when info button is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowInfo: true,
|
||||
isShowDelete: false,
|
||||
isShowFetchNewVersion: false,
|
||||
meta: {
|
||||
repo: 'owner/repo-name',
|
||||
version: '2.0.0',
|
||||
package: 'my-package.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-repo', 'owner/repo-name')
|
||||
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-release', '2.0.0')
|
||||
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-package', 'my-package.difypkg')
|
||||
})
|
||||
|
||||
it('should hide plugin info modal when close is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowInfo: true,
|
||||
isShowDelete: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-plugin-info'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('plugin-info-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Check for Updates Tests ====================
|
||||
describe('Check for Updates', () => {
|
||||
it('should fetch releases when check for updates button is clicked', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
meta: {
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use author and pluginName as fallback for empty repo parts', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
author: 'fallback-author',
|
||||
pluginName: 'fallback-plugin',
|
||||
meta: {
|
||||
repo: '/', // Results in empty parts after split
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-plugin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not proceed if no releases are fetched', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockCheckForUpdates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show toast notification after checking for updates', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '2.0.0' }])
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: false,
|
||||
toastProps: { type: 'success', message: 'Already up to date' },
|
||||
})
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert - Toast.notify is called with the toast props
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show update modal when update is available', async () => {
|
||||
// Arrange
|
||||
const releases = [{ version: '2.0.0' }]
|
||||
mockFetchReleases.mockResolvedValue(releases)
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: true,
|
||||
toastProps: { type: 'info', message: 'Update available' },
|
||||
})
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
pluginUniqueIdentifier: 'test-id',
|
||||
category: 'model' as PluginCategoryEnum,
|
||||
meta: {
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
type: PluginSource.github,
|
||||
category: 'model',
|
||||
github: expect.objectContaining({
|
||||
originalPackageInfo: expect.objectContaining({
|
||||
id: 'test-id',
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
releases,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call invalidateInstalledPluginList on save callback', async () => {
|
||||
// Arrange
|
||||
const releases = [{ version: '2.0.0' }]
|
||||
mockFetchReleases.mockResolvedValue(releases)
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: true,
|
||||
toastProps: { type: 'info', message: 'Update available' },
|
||||
})
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Wait for modal to be called
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Invoke the callback
|
||||
const call = mockSetShowUpdatePluginModal.mock.calls[0][0]
|
||||
call.onSaveCallback()
|
||||
|
||||
// Assert
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should check updates with current version', async () => {
|
||||
// Arrange
|
||||
const releases = [{ version: '2.0.0' }, { version: '1.5.0' }]
|
||||
mockFetchReleases.mockResolvedValue(releases)
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
meta: {
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCheckForUpdates).toHaveBeenCalledWith(releases, '1.0.0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Callback Stability Tests ====================
|
||||
describe('Callback Stability (useCallback)', () => {
|
||||
it('should have stable handleDelete callback with same dependencies', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const onDelete = vi.fn()
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete,
|
||||
installationId: 'stable-install-id',
|
||||
})
|
||||
|
||||
// Act - First render and delete
|
||||
const { rerender } = render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
|
||||
})
|
||||
|
||||
// Re-render with same props
|
||||
mockUninstallPlugin.mockClear()
|
||||
rerender(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update handleDelete when installationId changes', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const props1 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
installationId: 'install-1',
|
||||
})
|
||||
const props2 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
installationId: 'install-2',
|
||||
})
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<Action {...props1} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1')
|
||||
})
|
||||
|
||||
mockUninstallPlugin.mockClear()
|
||||
rerender(<Action {...props2} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update handleDelete when onDelete changes', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const onDelete1 = vi.fn()
|
||||
const onDelete2 = vi.fn()
|
||||
const props1 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete: onDelete1,
|
||||
})
|
||||
const props2 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete: onDelete2,
|
||||
})
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<Action {...props1} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDelete1).toHaveBeenCalled()
|
||||
})
|
||||
expect(onDelete2).not.toHaveBeenCalled()
|
||||
|
||||
rerender(<Action {...props2} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDelete2).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined meta for info display', () => {
|
||||
// Arrange - meta is required for info, but test defensive behavior
|
||||
const props = createActionProps({
|
||||
isShowInfo: false,
|
||||
isShowDelete: true,
|
||||
isShowFetchNewVersion: false,
|
||||
meta: undefined,
|
||||
})
|
||||
|
||||
// Act & Assert - Should not crash
|
||||
expect(() => render(<Action {...props} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle empty repo string', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
author: 'fallback-owner',
|
||||
pluginName: 'fallback-repo',
|
||||
meta: {
|
||||
repo: '',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert - Should use author and pluginName as fallback
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-owner', 'fallback-repo')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle concurrent delete requests gracefully', async () => {
|
||||
// Arrange
|
||||
let resolveFirst: (value: { success: boolean }) => void
|
||||
const firstPromise = new Promise<{ success: boolean }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
mockUninstallPlugin.mockReturnValueOnce(firstPromise)
|
||||
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// The confirm button should be disabled during deletion
|
||||
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
|
||||
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true')
|
||||
|
||||
// Resolve the deletion
|
||||
resolveFirst!({ success: true })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in plugin name', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
pluginName: 'plugin-with-special@chars#123',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('plugin-with-special@chars#123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== React.memo Tests ====================
|
||||
describe('React.memo Behavior', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Assert
|
||||
expect(Action).toBeDefined()
|
||||
expect((Action as any).$$typeof?.toString()).toContain('Symbol')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Prop Variations ====================
|
||||
describe('Prop Variations', () => {
|
||||
it('should handle all category types', () => {
|
||||
// Arrange
|
||||
const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const props = createActionProps({
|
||||
category,
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
expect(() => render(<Action {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle different usedInApps values', () => {
|
||||
// Arrange
|
||||
const values = [0, 1, 5, 100]
|
||||
|
||||
values.forEach((usedInApps) => {
|
||||
const props = createActionProps({
|
||||
usedInApps,
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
expect(() => render(<Action {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle combination of multiple action buttons', () => {
|
||||
// Arrange - Test various combinations
|
||||
const combinations = [
|
||||
{ isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: false },
|
||||
{ isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: false },
|
||||
{ isShowFetchNewVersion: false, isShowInfo: false, isShowDelete: true },
|
||||
{ isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: false },
|
||||
{ isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: true },
|
||||
{ isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: true },
|
||||
{ isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: true },
|
||||
]
|
||||
|
||||
combinations.forEach((flags) => {
|
||||
const props = createActionProps(flags)
|
||||
const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length
|
||||
|
||||
const { unmount } = render(<Action {...props} />)
|
||||
const buttons = queryActionButtons()
|
||||
expect(buttons).toHaveLength(expectedCount)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1016
web/app/components/plugins/plugin-item/index.spec.tsx
Normal file
1016
web/app/components/plugins/plugin-item/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1162
web/app/components/plugins/plugin-mutation-model/index.spec.tsx
Normal file
1162
web/app/components/plugins/plugin-mutation-model/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
583
web/app/components/plugins/plugin-page/empty/index.spec.tsx
Normal file
583
web/app/components/plugins/plugin-page/empty/index.spec.tsx
Normal file
@ -0,0 +1,583 @@
|
||||
import type { FilterState } from '../filter-management'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defaultSystemFeatures, InstallationScope } from '@/types/feature'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import Empty from './index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Use vi.hoisted to define ALL mock state and functions
|
||||
const {
|
||||
mockSetActiveTab,
|
||||
mockUseInstalledPluginList,
|
||||
mockState,
|
||||
stableT,
|
||||
} = vi.hoisted(() => {
|
||||
const state = {
|
||||
filters: {
|
||||
categories: [] as string[],
|
||||
tags: [] as string[],
|
||||
searchQuery: '',
|
||||
} as FilterState,
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: 'all' as const,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
} as Partial<SystemFeatures>,
|
||||
pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined,
|
||||
}
|
||||
// Stable t function to prevent infinite re-renders
|
||||
// The component's useEffect and useMemo depend on t
|
||||
const t = (key: string) => key
|
||||
return {
|
||||
mockSetActiveTab: vi.fn(),
|
||||
mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })),
|
||||
mockState: state,
|
||||
stableT: t,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock plugin page context
|
||||
vi.mock('../context', () => ({
|
||||
usePluginPageContext: (selector: (value: any) => any) => {
|
||||
const contextValue = {
|
||||
filters: mockState.filters,
|
||||
setActiveTab: mockSetActiveTab,
|
||||
}
|
||||
return selector(contextValue)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock global public store (Zustand store)
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: any) => any) => {
|
||||
return selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
...mockState.systemFeatures,
|
||||
},
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useInstalledPluginList hook
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => mockUseInstalledPluginList(),
|
||||
}))
|
||||
|
||||
// Mock InstallFromGitHub component
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
|
||||
default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => (
|
||||
<div data-testid="install-from-github-modal">
|
||||
<button data-testid="github-modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="github-modal-success">Success</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock InstallFromLocalPackage component
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({
|
||||
default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => (
|
||||
<div data-testid="install-from-local-modal" data-file-name={file.name}>
|
||||
<button data-testid="local-modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="local-modal-success">Success</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Line component
|
||||
vi.mock('../../marketplace/empty/line', () => ({
|
||||
default: ({ className }: { className?: string }) => <div data-testid="line-component" className={className} />,
|
||||
}))
|
||||
|
||||
// Override react-i18next with stable t function reference to prevent infinite re-renders
|
||||
// The component's useEffect and useMemo depend on t, so it MUST be stable
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: stableT,
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const resetMockState = () => {
|
||||
mockState.filters = { categories: [], tags: [], searchQuery: '' }
|
||||
mockState.systemFeatures = {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
}
|
||||
mockState.pluginList = { plugins: [] }
|
||||
mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList })
|
||||
}
|
||||
|
||||
const setMockFilters = (filters: Partial<FilterState>) => {
|
||||
mockState.filters = { ...mockState.filters, ...filters }
|
||||
}
|
||||
|
||||
const setMockSystemFeatures = (features: Partial<SystemFeatures>) => {
|
||||
mockState.systemFeatures = { ...mockState.systemFeatures, ...features }
|
||||
}
|
||||
|
||||
const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => {
|
||||
mockState.pluginList = list
|
||||
mockUseInstalledPluginList.mockReturnValue({ data: list })
|
||||
}
|
||||
|
||||
const createMockFile = (name: string, type = 'application/octet-stream'): File => {
|
||||
return new File(['test'], name, { type })
|
||||
}
|
||||
|
||||
// Helper to wait for useEffect to complete (single tick)
|
||||
const flushEffects = async () => {
|
||||
await act(async () => {})
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('Empty Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockState()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render basic structure correctly', async () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - file input
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(fileInput).toBeInTheDocument()
|
||||
expect(fileInput.style.display).toBe('none')
|
||||
expect(fileInput.accept).toBe('.difypkg,.difybndl')
|
||||
|
||||
// Assert - skeleton cards (20 in the grid + 1 icon container)
|
||||
const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg')
|
||||
expect(skeletonCards.length).toBeGreaterThanOrEqual(20)
|
||||
|
||||
// Assert - group icon container
|
||||
const iconContainer = document.querySelector('.size-14')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
|
||||
// Assert - line components
|
||||
const lines = screen.getAllByTestId('line-component')
|
||||
expect(lines).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Text Display Tests (useMemo) ====================
|
||||
describe('Text Display (useMemo)', () => {
|
||||
it('should display "noInstalled" text when plugin list is empty', async () => {
|
||||
// Arrange
|
||||
setMockPluginList({ plugins: [] })
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "notFound" text when filters are active with plugins', async () => {
|
||||
// Arrange
|
||||
setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
|
||||
|
||||
// Test categories filter
|
||||
setMockFilters({ categories: ['model'] })
|
||||
const { rerender } = render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
|
||||
// Test tags filter
|
||||
setMockFilters({ categories: [], tags: ['tag1'] })
|
||||
rerender(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
|
||||
// Test searchQuery filter
|
||||
setMockFilters({ tags: [], searchQuery: 'test query' })
|
||||
rerender(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => {
|
||||
// Arrange
|
||||
setMockFilters({ categories: ['model'], searchQuery: 'test' })
|
||||
setMockPluginList({ plugins: [] })
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Install Methods Tests (useEffect) ====================
|
||||
describe('Install Methods (useEffect)', () => {
|
||||
it('should render all three install methods when marketplace enabled and not restricted', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(screen.getByText('source.marketplace')).toBeInTheDocument()
|
||||
expect(screen.getByText('source.github')).toBeInTheDocument()
|
||||
expect(screen.getByText('source.local')).toBeInTheDocument()
|
||||
|
||||
// Verify button order
|
||||
const buttonTexts = buttons.map(btn => btn.textContent)
|
||||
expect(buttonTexts[0]).toContain('source.marketplace')
|
||||
expect(buttonTexts[1]).toContain('source.github')
|
||||
expect(buttonTexts[2]).toContain('source.local')
|
||||
})
|
||||
|
||||
it('should render only marketplace method when restricted to marketplace only', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(screen.getByText('source.marketplace')).toBeInTheDocument()
|
||||
expect(screen.queryByText('source.github')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('source.local')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render github and local methods when marketplace is disabled', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: false,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(screen.queryByText('source.marketplace')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('source.github')).toBeInTheDocument()
|
||||
expect(screen.getByText('source.local')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no methods when marketplace disabled and restricted', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: false,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== User Interactions Tests ====================
|
||||
describe('User Interactions', () => {
|
||||
it('should call setActiveTab with "discover" when marketplace button is clicked', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('source.marketplace'))
|
||||
|
||||
// Assert
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith('discover')
|
||||
})
|
||||
|
||||
it('should open and close GitHub modal correctly', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - initially no modal
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Act - open modal
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
|
||||
// Assert - modal is open
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
|
||||
// Act - close modal
|
||||
fireEvent.click(screen.getByTestId('github-modal-close'))
|
||||
|
||||
// Assert - modal is closed
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger file input click when local button is clicked', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('source.local'))
|
||||
|
||||
// Assert
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open and close local modal when file is selected', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const mockFile = createMockFile('test-plugin.difypkg')
|
||||
|
||||
// Assert - initially no modal
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Act - select file
|
||||
Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
// Assert - modal is open with correct file
|
||||
expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg')
|
||||
|
||||
// Act - close modal
|
||||
fireEvent.click(screen.getByTestId('local-modal-close'))
|
||||
|
||||
// Assert - modal is closed
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open local modal when no file is selected', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Act - trigger change with empty files
|
||||
Object.defineProperty(fileInput, 'files', { value: [], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== State Management Tests ====================
|
||||
describe('State Management', () => {
|
||||
it('should maintain modal state correctly and allow reopening', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Act - Open, close, and reopen GitHub modal
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-modal-close'))
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selectedFile state when file is selected', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Act - select .difypkg file
|
||||
Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg')
|
||||
|
||||
// Close and select .difybndl file
|
||||
fireEvent.click(screen.getByTestId('local-modal-close'))
|
||||
Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Side Effects Tests ====================
|
||||
describe('Side Effects', () => {
|
||||
it('should render correct install methods based on system features', async () => {
|
||||
// Test 1: All methods when marketplace enabled and not restricted
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { unmount: unmount1 } = render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
unmount1()
|
||||
|
||||
// Test 2: Only marketplace when restricted
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: true,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getByText('source.marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correct text based on plugin list and filters', async () => {
|
||||
// Test 1: noInstalled when plugin list is empty
|
||||
setMockPluginList({ plugins: [] })
|
||||
setMockFilters({ categories: [], tags: [], searchQuery: '' })
|
||||
|
||||
const { unmount: unmount1 } = render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
|
||||
unmount1()
|
||||
|
||||
// Test 2: notFound when filters are active with plugins
|
||||
setMockFilters({ categories: ['tool'] })
|
||||
setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
|
||||
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined plugin data gracefully', () => {
|
||||
// Test undefined plugin list - component should render without error
|
||||
setMockPluginList(undefined)
|
||||
expect(() => render(<Empty />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle file input edge cases', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Test undefined files
|
||||
Object.defineProperty(fileInput, 'files', { value: undefined, writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== React.memo Tests ====================
|
||||
describe('React.memo Behavior', () => {
|
||||
it('should be wrapped with React.memo and have displayName', () => {
|
||||
// Assert
|
||||
expect(Empty).toBeDefined()
|
||||
expect((Empty as any).$$typeof?.toString()).toContain('Symbol')
|
||||
expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Modal Callbacks Tests ====================
|
||||
describe('Modal Callbacks', () => {
|
||||
it('should handle modal onSuccess callbacks (noop)', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Test GitHub modal onSuccess
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
fireEvent.click(screen.getByTestId('github-modal-success'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
|
||||
// Close GitHub modal and test Local modal onSuccess
|
||||
fireEvent.click(screen.getByTestId('github-modal-close'))
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
fireEvent.click(screen.getByTestId('local-modal-success'))
|
||||
expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Conditional Modal Rendering ====================
|
||||
describe('Conditional Modal Rendering', () => {
|
||||
it('should only render one modal at a time and require file for local modal', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - no modals initially
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Open GitHub modal - only GitHub modal visible
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Click local button - triggers file input, no modal yet (no file selected)
|
||||
fireEvent.click(screen.getByText('source.local'))
|
||||
// GitHub modal should still be visible, local modal requires file selection
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,7 @@ import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
import TabSlider from '@/app/components/base/tab-slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal/modal'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
|
||||
import { getDocsUrl } from '@/app/components/plugins/utils'
|
||||
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
702
web/app/components/plugins/plugin-page/list/index.spec.tsx
Normal file
702
web/app/components/plugins/plugin-page/list/index.spec.tsx
Normal file
@ -0,0 +1,702 @@
|
||||
import type { PluginDeclaration, PluginDetail } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import PluginList from './index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Mock PluginItem component to avoid complex dependency chain
|
||||
vi.mock('../../plugin-item', () => ({
|
||||
default: ({ plugin }: { plugin: PluginDetail }) => (
|
||||
<div
|
||||
data-testid="plugin-item"
|
||||
data-plugin-id={plugin.plugin_id}
|
||||
data-plugin-name={plugin.name}
|
||||
>
|
||||
{plugin.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
/**
|
||||
* Factory function to create a PluginDeclaration with defaults
|
||||
*/
|
||||
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
icon_dark: 'test-icon-dark.png',
|
||||
name: 'test-plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { en_US: 'Test Plugin' } as any,
|
||||
description: { en_US: 'Test plugin description' } as any,
|
||||
created_at: '2024-01-01',
|
||||
resource: null,
|
||||
plugins: null,
|
||||
verified: false,
|
||||
endpoint: {} as any,
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
minimum_dify_version: '0.5.0',
|
||||
},
|
||||
trigger: {} as any,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a PluginDetail with defaults
|
||||
*/
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'plugin-1',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
name: 'test-plugin',
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'test-author/test-plugin@1.0.0',
|
||||
declaration: createPluginDeclaration(),
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'test-author/test-plugin@1.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
meta: {
|
||||
repo: 'test-author/test-plugin',
|
||||
version: '1.0.0',
|
||||
package: 'test-plugin.difypkg',
|
||||
},
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a list of plugins
|
||||
*/
|
||||
const createPluginList = (count: number, baseOverrides: Partial<PluginDetail> = {}): PluginDetail[] => {
|
||||
return Array.from({ length: count }, (_, index) => createPluginDetail({
|
||||
id: `plugin-${index + 1}`,
|
||||
plugin_id: `plugin-${index + 1}`,
|
||||
name: `plugin-${index + 1}`,
|
||||
plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`,
|
||||
...baseOverrides,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('PluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const pluginList: PluginDetail[] = []
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render container with correct structure', () => {
|
||||
// Arrange
|
||||
const pluginList: PluginDetail[] = []
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pb-3')
|
||||
|
||||
const gridDiv = outerDiv.firstChild as HTMLElement
|
||||
expect(gridDiv).toHaveClass('grid', 'grid-cols-2', 'gap-3')
|
||||
})
|
||||
|
||||
it('should render single plugin correctly', () => {
|
||||
// Arrange
|
||||
const pluginList = [createPluginDetail({ name: 'single-plugin' })]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(1)
|
||||
expect(pluginItems[0]).toHaveAttribute('data-plugin-name', 'single-plugin')
|
||||
})
|
||||
|
||||
it('should render multiple plugins correctly', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(5)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('should render plugins in correct order', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'first', name: 'First Plugin' }),
|
||||
createPluginDetail({ plugin_id: 'second', name: 'Second Plugin' }),
|
||||
createPluginDetail({ plugin_id: 'third', name: 'Third Plugin' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems[0]).toHaveAttribute('data-plugin-id', 'first')
|
||||
expect(pluginItems[1]).toHaveAttribute('data-plugin-id', 'second')
|
||||
expect(pluginItems[2]).toHaveAttribute('data-plugin-id', 'third')
|
||||
})
|
||||
|
||||
it('should pass plugin prop to each PluginItem', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'plugin-a', name: 'Plugin A' }),
|
||||
createPluginDetail({ plugin_id: 'plugin-b', name: 'Plugin B' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Plugin A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugin B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should accept empty pluginList array', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<PluginList pluginList={[]} />)
|
||||
|
||||
// Assert
|
||||
const gridDiv = container.querySelector('.grid')
|
||||
expect(gridDiv).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle pluginList with various categories', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'tool-plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'model-plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'extension-plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }),
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle pluginList with various sources', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'marketplace-plugin', source: PluginSource.marketplace }),
|
||||
createPluginDetail({ plugin_id: 'github-plugin', source: PluginSource.github }),
|
||||
createPluginDetail({ plugin_id: 'local-plugin', source: PluginSource.local }),
|
||||
createPluginDetail({ plugin_id: 'debugging-plugin', source: PluginSource.debugging }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty array', () => {
|
||||
// Arrange & Act
|
||||
render(<PluginList pluginList={[]} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large number of plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(100)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('should handle plugins with duplicate plugin_ids (key warning scenario)', () => {
|
||||
// Arrange - Testing that the component uses plugin_id as key
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'unique-1', name: 'Plugin 1' }),
|
||||
createPluginDetail({ plugin_id: 'unique-2', name: 'Plugin 2' }),
|
||||
]
|
||||
|
||||
// Act & Assert - Should render without issues
|
||||
expect(() => render(<PluginList pluginList={pluginList} />)).not.toThrow()
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle plugins with special characters in names', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'special-1', name: 'Plugin <with> "special" & chars' }),
|
||||
createPluginDetail({ plugin_id: 'special-2', name: '日本語プラグイン' }),
|
||||
createPluginDetail({ plugin_id: 'special-3', name: 'Emoji Plugin 🔌' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle plugins with very long names', () => {
|
||||
// Arrange
|
||||
const longName = 'A'.repeat(500)
|
||||
const pluginList = [createPluginDetail({ name: longName })]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plugin with minimal data', () => {
|
||||
// Arrange
|
||||
const minimalPlugin = createPluginDetail({
|
||||
name: '',
|
||||
plugin_id: 'minimal',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={[minimalPlugin]} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plugins with undefined optional fields', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'no-meta',
|
||||
meta: undefined,
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Grid Layout Tests ====================
|
||||
describe('Grid Layout', () => {
|
||||
it('should render with 2-column grid', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(4)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const gridDiv = container.querySelector('.grid')
|
||||
expect(gridDiv).toHaveClass('grid-cols-2')
|
||||
})
|
||||
|
||||
it('should have proper gap between items', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(4)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const gridDiv = container.querySelector('.grid')
|
||||
expect(gridDiv).toHaveClass('gap-3')
|
||||
})
|
||||
|
||||
it('should have bottom padding on container', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(2)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pb-3')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Re-render Tests ====================
|
||||
describe('Re-render Behavior', () => {
|
||||
it('should update when pluginList changes', () => {
|
||||
// Arrange
|
||||
const initialList = createPluginList(2)
|
||||
const updatedList = createPluginList(4)
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={initialList} />)
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
|
||||
rerender(<PluginList pluginList={updatedList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should handle pluginList update from non-empty to empty', () => {
|
||||
// Arrange
|
||||
const initialList = createPluginList(3)
|
||||
const emptyList: PluginDetail[] = []
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={initialList} />)
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
|
||||
|
||||
rerender(<PluginList pluginList={emptyList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pluginList update from empty to non-empty', () => {
|
||||
// Arrange
|
||||
const emptyList: PluginDetail[] = []
|
||||
const filledList = createPluginList(3)
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={emptyList} />)
|
||||
expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<PluginList pluginList={filledList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should update individual plugin data on re-render', () => {
|
||||
// Arrange
|
||||
const initialList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Original Name' })]
|
||||
const updatedList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Updated Name' })]
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={initialList} />)
|
||||
expect(screen.getByText('Original Name')).toBeInTheDocument()
|
||||
|
||||
rerender(<PluginList pluginList={updatedList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Updated Name')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Original Name')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Key Prop Tests ====================
|
||||
describe('Key Prop Behavior', () => {
|
||||
it('should use plugin_id as key for efficient re-renders', () => {
|
||||
// Arrange - Create plugins with unique plugin_ids
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'stable-key-1', name: 'Plugin 1' }),
|
||||
createPluginDetail({ plugin_id: 'stable-key-2', name: 'Plugin 2' }),
|
||||
createPluginDetail({ plugin_id: 'stable-key-3', name: 'Plugin 3' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Reorder the list
|
||||
const reorderedList = [pluginList[2], pluginList[0], pluginList[1]]
|
||||
rerender(<PluginList pluginList={reorderedList} />)
|
||||
|
||||
// Assert - All items should still be present
|
||||
const items = screen.getAllByTestId('plugin-item')
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]).toHaveAttribute('data-plugin-id', 'stable-key-3')
|
||||
expect(items[1]).toHaveAttribute('data-plugin-id', 'stable-key-1')
|
||||
expect(items[2]).toHaveAttribute('data-plugin-id', 'stable-key-2')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Plugin Status Variations ====================
|
||||
describe('Plugin Status Variations', () => {
|
||||
it('should render active plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = [createPluginDetail({ status: 'active' })]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render deleted/deprecated plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
status: 'deleted',
|
||||
deprecated_reason: 'No longer maintained',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render mixed status plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'active-plugin', status: 'active' }),
|
||||
createPluginDetail({
|
||||
plugin_id: 'deprecated-plugin',
|
||||
status: 'deleted',
|
||||
deprecated_reason: 'Deprecated',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Version Variations ====================
|
||||
describe('Version Variations', () => {
|
||||
it('should render plugins with same version as latest', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugins with outdated version', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Accessibility ====================
|
||||
describe('Accessibility', () => {
|
||||
it('should render as a semantic container', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(2)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert - The list is rendered as divs which is appropriate for a grid layout
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv.tagName).toBe('DIV')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Type ====================
|
||||
describe('Component Type', () => {
|
||||
it('should be a functional component', () => {
|
||||
// Assert
|
||||
expect(typeof PluginList).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept pluginList as required prop', () => {
|
||||
// Arrange & Act - TypeScript ensures this at compile time
|
||||
// but we verify runtime behavior
|
||||
const pluginList = createPluginList(1)
|
||||
|
||||
// Assert
|
||||
expect(() => render(<PluginList pluginList={pluginList} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Mixed Content Tests ====================
|
||||
describe('Mixed Content', () => {
|
||||
it('should render plugins from different sources together', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'marketplace-1',
|
||||
name: 'Marketplace Plugin',
|
||||
source: PluginSource.marketplace,
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'github-1',
|
||||
name: 'GitHub Plugin',
|
||||
source: PluginSource.github,
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'local-1',
|
||||
name: 'Local Plugin',
|
||||
source: PluginSource.local,
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Marketplace Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('GitHub Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('Local Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugins of different categories together', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'tool-1',
|
||||
name: 'Tool Plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'model-1',
|
||||
name: 'Model Plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'agent-1',
|
||||
name: 'Agent Plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.agent }),
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Tool Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('Model Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Plugin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Boundary Tests ====================
|
||||
describe('Boundary Tests', () => {
|
||||
it('should handle single item list', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(1)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle two items (fills one row)', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(2)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle three items (partial second row)', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(3)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle odd number of items', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(7)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should handle even number of items', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(8)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
893
web/app/components/plugins/readme-panel/index.spec.tsx
Normal file
893
web/app/components/plugins/readme-panel/index.spec.tsx
Normal file
@ -0,0 +1,893 @@
|
||||
import type { PluginDetail } from '../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
||||
import { BUILTIN_TOOLS_ARRAY } from './constants'
|
||||
import { ReadmeEntrance } from './entrance'
|
||||
import ReadmePanel from './index'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock usePluginReadme hook
|
||||
const mockUsePluginReadme = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params),
|
||||
}))
|
||||
|
||||
// Mock useLanguage hook
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// Mock DetailHeader component (complex component with many dependencies)
|
||||
vi.mock('../plugin-detail-panel/detail-header', () => ({
|
||||
default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
|
||||
<div data-testid="detail-header" data-is-readme-view={isReadmeView}>
|
||||
{detail.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Data Factories
|
||||
// ================================
|
||||
|
||||
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-plugin-id',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
name: 'test-plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
plugin_unique_identifier: 'test-plugin@1.0.0',
|
||||
declaration: {
|
||||
plugin_unique_identifier: 'test-plugin@1.0.0',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
name: 'test-plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': 'Test Plugin' } as Record<string, string>,
|
||||
description: { 'en-US': 'Test plugin description' } as Record<string, string>,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
resource: null,
|
||||
plugins: null,
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {
|
||||
events: [],
|
||||
identity: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin',
|
||||
label: { 'en-US': 'Test Plugin' } as Record<string, string>,
|
||||
description: { 'en-US': 'Test plugin description' } as Record<string, string>,
|
||||
icon: 'test-icon.png',
|
||||
tags: [],
|
||||
},
|
||||
subscription_constructor: {
|
||||
credentials_schema: [],
|
||||
oauth_schema: { client_schema: [], credentials_schema: [] },
|
||||
parameters: [],
|
||||
},
|
||||
subscription_schema: [],
|
||||
},
|
||||
},
|
||||
installation_id: 'install-123',
|
||||
tenant_id: 'tenant-123',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'test-plugin@1.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
status: 'active' as const,
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Test Utilities
|
||||
// ================================
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Constants Tests
|
||||
// ================================
|
||||
describe('BUILTIN_TOOLS_ARRAY', () => {
|
||||
it('should contain expected builtin tools', () => {
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('code')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('audio')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('time')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('webscraper')
|
||||
})
|
||||
|
||||
it('should have exactly 4 builtin tools', () => {
|
||||
expect(BUILTIN_TOOLS_ARRAY).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Store Tests
|
||||
// ================================
|
||||
describe('useReadmePanelStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset store state before each test
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail()
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should have undefined currentPluginDetail initially', () => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentPluginDetail', () => {
|
||||
it('should set currentPluginDetail with detail and default showType', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('should set currentPluginDetail with custom showType', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.modal,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear currentPluginDetail when called without arguments', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// First set a detail
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail)
|
||||
})
|
||||
|
||||
// Then clear it
|
||||
act(() => {
|
||||
setCurrentPluginDetail()
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear currentPluginDetail when called with undefined', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// First set a detail
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail)
|
||||
})
|
||||
|
||||
// Then clear it with explicit undefined
|
||||
act(() => {
|
||||
setCurrentPluginDetail(undefined)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadmeShowType enum', () => {
|
||||
it('should have drawer and modal types', () => {
|
||||
expect(ReadmeShowType.drawer).toBe('drawer')
|
||||
expect(ReadmeShowType.modal).toBe('modal')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ReadmeEntrance Component Tests
|
||||
// ================================
|
||||
describe('ReadmeEntrance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset store state
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render the entrance button with full tip text', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.readmeInfo.needHelpCheckReadme')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with short tip text when showShortTip is true', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider when showShortTip is false', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip={false} />)
|
||||
|
||||
expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showShortTip is true', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip />)
|
||||
|
||||
expect(container.querySelector('.bg-divider-regular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply drawer mode padding class', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(
|
||||
<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.drawer} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.px-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(
|
||||
<ReadmeEntrance pluginDetail={mockDetail} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Conditional Rendering / Edge Cases
|
||||
// ================================
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should return null when pluginDetail is null/undefined', () => {
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={null as unknown as PluginDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when plugin_unique_identifier is missing', () => {
|
||||
const mockDetail = createMockPluginDetail({ plugin_unique_identifier: '' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: code', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'code' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: audio', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'audio' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: time', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'time' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: webscraper', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'webscraper' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render for non-builtin plugins', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'custom-plugin' })
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions / Event Handlers
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call setCurrentPluginDetail with drawer type when clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setCurrentPluginDetail with modal type when clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.modal} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.modal,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Prop Variations
|
||||
// ================================
|
||||
describe('Prop Variations', () => {
|
||||
it('should use default showType when not provided', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
it('should handle modal showType correctly', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.modal} />)
|
||||
|
||||
// Modal mode should not have px-4 class
|
||||
const container = screen.getByRole('button').parentElement
|
||||
expect(container).not.toHaveClass('px-4')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ReadmePanel Component Tests
|
||||
// ================================
|
||||
describe('ReadmePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset store state
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail()
|
||||
// Reset mock
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should return null when no plugin detail is set', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render portal content when plugin detail is set', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render DetailHeader component', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// ActionButton wraps the close icon
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Loading State Tests
|
||||
// ================================
|
||||
describe('Loading State', () => {
|
||||
it('should show loading indicator when isLoading is true', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Loading component should be rendered with role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error State Tests
|
||||
// ================================
|
||||
describe('Error State', () => {
|
||||
it('should show error message when error occurs', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// No Readme Available State Tests
|
||||
// ================================
|
||||
describe('No Readme Available', () => {
|
||||
it('should show no readme message when readme is empty', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no readme message when data is null', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Markdown Content Tests
|
||||
// ================================
|
||||
describe('Markdown Content', () => {
|
||||
it('should render markdown container when readme is available', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Markdown component container should be rendered
|
||||
// Note: The Markdown component uses dynamic import, so content may load asynchronously
|
||||
const markdownContainer = document.querySelector('.markdown-body')
|
||||
expect(markdownContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show error or no-readme message when readme is available', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Should not show error or no-readme message
|
||||
expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Rendering Tests (Drawer Mode)
|
||||
// ================================
|
||||
describe('Portal Rendering - Drawer Mode', () => {
|
||||
it('should render drawer styled container in drawer mode', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Drawer mode has specific max-width
|
||||
const drawerContainer = document.querySelector('.max-w-\\[600px\\]')
|
||||
expect(drawerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct drawer positioning classes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Check for drawer-specific classes
|
||||
const backdrop = document.querySelector('.justify-start')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Rendering Tests (Modal Mode)
|
||||
// ================================
|
||||
describe('Portal Rendering - Modal Mode', () => {
|
||||
it('should render modal styled container in modal mode', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Modal mode has different max-width
|
||||
const modalContainer = document.querySelector('.max-w-\\[800px\\]')
|
||||
expect(modalContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct modal positioning classes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Check for modal-specific classes
|
||||
const backdrop = document.querySelector('.items-center.justify-center')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions / Event Handlers
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should close panel when close button is clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should close panel when backdrop is clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the backdrop (outer div)
|
||||
const backdrop = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not close panel when content area is clicked', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the content container (should stop propagation)
|
||||
const contentContainer = document.querySelector('.pointer-events-auto')
|
||||
fireEvent.click(contentContainer!)
|
||||
|
||||
await waitFor(() => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// API Call Tests
|
||||
// ================================
|
||||
describe('API Calls', () => {
|
||||
it('should call usePluginReadme with correct parameters', () => {
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass undefined language for zh-Hans locale', () => {
|
||||
// Re-mock useLanguage to return zh-Hans
|
||||
vi.doMock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'zh-Hans',
|
||||
}))
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
// This test verifies the language handling logic exists in the component
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// The component should have called the hook
|
||||
expect(mockUsePluginReadme).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty plugin_unique_identifier', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: '',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: '',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle detail with missing declaration', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
// Simulate missing fields
|
||||
delete (mockDetail as Partial<PluginDetail>).declaration
|
||||
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// This should not throw
|
||||
expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle rapid open/close operations', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Rapidly toggle the panel
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
setCurrentPluginDetail()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('should handle switching between drawer and modal modes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Start with drawer
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
let state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
|
||||
|
||||
// Switch to modal
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('should handle undefined detail gracefully', () => {
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Set to undefined explicitly
|
||||
act(() => {
|
||||
setCurrentPluginDetail(undefined, ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Integration', () => {
|
||||
it('should work correctly when opened from ReadmeEntrance', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Integration Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Render both components
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<>
|
||||
<ReadmeEntrance pluginDetail={mockDetail} />
|
||||
<ReadmePanel />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Initially panel should not show content
|
||||
expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument()
|
||||
|
||||
// Click the entrance button
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Re-render to pick up store changes
|
||||
rerender(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ReadmeEntrance pluginDetail={mockDetail} />
|
||||
<ReadmePanel />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Panel should now show content
|
||||
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
|
||||
// Markdown content renders in a container (dynamic import may not render content synchronously)
|
||||
expect(document.querySelector('.markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct plugin information in header', () => {
|
||||
const mockDetail = createMockPluginDetail({
|
||||
name: 'my-awesome-plugin',
|
||||
})
|
||||
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
1042
web/app/components/plugins/reference-setting-modal/index.spec.tsx
Normal file
1042
web/app/components/plugins/reference-setting-modal/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1237
web/app/components/plugins/update-plugin/index.spec.tsx
Normal file
1237
web/app/components/plugins/update-plugin/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,156 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockSetCurrentVersion = vi.fn()
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => ({ id: 'test-user-id' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidAllLastRun: () => vi.fn(),
|
||||
useResetWorkflowVersionHistory: () => vi.fn(),
|
||||
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useWorkflowVersionHistory: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: 'draft-version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
graph: { nodes: [], edges: [], viewport: null },
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { image: { enabled: false } },
|
||||
},
|
||||
created_at: Date.now() / 1000,
|
||||
created_by: { id: 'user-1', name: 'User 1' },
|
||||
environment_variables: [],
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
},
|
||||
{
|
||||
id: 'published-version-id',
|
||||
version: '2024-01-01T00:00:00Z',
|
||||
graph: { nodes: [], edges: [], viewport: null },
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { image: { enabled: false } },
|
||||
},
|
||||
created_at: Date.now() / 1000,
|
||||
created_by: { id: 'user-1', name: 'User 1' },
|
||||
environment_variables: [],
|
||||
marked_name: 'v1.0',
|
||||
marked_comment: 'First release',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useDSL: () => ({ handleExportDSL: vi.fn() }),
|
||||
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
|
||||
useWorkflowRun: () => ({
|
||||
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks-store', () => ({
|
||||
useHooksStore: () => ({
|
||||
flowId: 'test-flow-id',
|
||||
flowType: 'workflow',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => {
|
||||
const state = {
|
||||
setShowWorkflowVersionHistoryPanel: vi.fn(),
|
||||
currentVersion: null,
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
deleteAllInspectVars: vi.fn(),
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./delete-confirm-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./restore-confirm-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
describe('VersionHistoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Version Click Behavior', () => {
|
||||
it('should call handleLoadBackupDraft when draft version is selected on mount', async () => {
|
||||
const { VersionHistoryPanel } = await import('./index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Draft version auto-clicks on mount via useEffect in VersionHistoryItem
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleRestoreFromPublishedWorkflow when clicking published version', async () => {
|
||||
const { VersionHistoryPanel } = await import('./index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Clear mocks after initial render (draft version auto-clicks on mount)
|
||||
vi.clearAllMocks()
|
||||
|
||||
const publishedItem = screen.getByText('v1.0')
|
||||
fireEvent.click(publishedItem)
|
||||
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
|
||||
expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -13,7 +13,7 @@ import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory
|
||||
import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks'
|
||||
import { useHooksStore } from '../../hooks-store'
|
||||
import { useStore, useWorkflowStore } from '../../store'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersionFilterOptions } from '../../types'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
|
||||
import DeleteConfirmModal from './delete-confirm-modal'
|
||||
import Empty from './empty'
|
||||
import Filter from './filter'
|
||||
@ -73,9 +73,12 @@ export const VersionHistoryPanel = ({
|
||||
const handleVersionClick = useCallback((item: VersionHistory) => {
|
||||
if (item.id !== currentVersion?.id) {
|
||||
setCurrentVersion(item)
|
||||
handleRestoreFromPublishedWorkflow(item)
|
||||
if (item.version === WorkflowVersion.Draft)
|
||||
handleLoadBackupDraft()
|
||||
else
|
||||
handleRestoreFromPublishedWorkflow(item)
|
||||
}
|
||||
}, [currentVersion?.id, setCurrentVersion, handleRestoreFromPublishedWorkflow])
|
||||
}, [currentVersion?.id, setCurrentVersion, handleLoadBackupDraft, handleRestoreFromPublishedWorkflow])
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasNextPage)
|
||||
|
||||
@ -8,8 +8,8 @@ import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { DatasetAttr } from '@/types/feature'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import I18nServer from './components/i18n-server'
|
||||
import { ReactScan } from './components/react-scan'
|
||||
import SentryInitializer from './components/sentry-initializer'
|
||||
import RoutePrefixHandle from './routePrefixHandle'
|
||||
import './styles/globals.css'
|
||||
@ -90,7 +90,7 @@ const LocaleLayout = async ({
|
||||
className="color-scheme h-full select-auto"
|
||||
{...datasetMap}
|
||||
>
|
||||
<ReactScan />
|
||||
<ReactScanLoader />
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
|
||||
@ -2,14 +2,7 @@
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
|
||||
const TanStackDevtoolsWrapper = lazy(() =>
|
||||
import('@/app/components/devtools').then(module => ({
|
||||
default: module.TanStackDevtoolsWrapper,
|
||||
})),
|
||||
)
|
||||
import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader'
|
||||
|
||||
const STALE_TIME = 1000 * 60 * 30 // 30 minutes
|
||||
|
||||
@ -26,11 +19,7 @@ export const TanstackQueryInitializer: FC<PropsWithChildren> = (props) => {
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
{children}
|
||||
{IS_DEV && (
|
||||
<Suspense fallback={null}>
|
||||
<TanStackDevtoolsWrapper />
|
||||
</Suspense>
|
||||
)}
|
||||
<TanStackDevtoolsLoader />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,8 +17,7 @@ web/i18n
|
||||
└── ...
|
||||
|
||||
web/i18n-config
|
||||
├── auto-gen-i18n.js
|
||||
├── check-i18n.js
|
||||
├── language.ts
|
||||
├── i18next-config.ts
|
||||
└── ...
|
||||
```
|
||||
@ -159,10 +158,10 @@ We have a list of languages that we support in the `languages.ts` file. But some
|
||||
|
||||
## Utility scripts
|
||||
|
||||
- Auto-fill translations: `pnpm run auto-gen-i18n --file app common --lang zh-Hans ja-JP [--dry-run]`
|
||||
- Auto-fill translations: `pnpm run i18n:gen --file app common --lang zh-Hans ja-JP [--dry-run]`
|
||||
- Use space-separated values; repeat `--file` / `--lang` as needed. Defaults to all en-US files and all supported locales except en-US.
|
||||
- Protects placeholders (`{{var}}`, `${var}`, `<tag>`) before translation and restores them after.
|
||||
- Check missing/extra keys: `pnpm run check-i18n --file app billing --lang zh-Hans [--auto-remove]`
|
||||
- Check missing/extra keys: `pnpm run i18n:check --file app billing --lang zh-Hans [--auto-remove]`
|
||||
- Use space-separated values; repeat `--file` / `--lang` as needed. Returns non-zero on missing/extra keys; `--auto-remove` deletes extra keys automatically.
|
||||
|
||||
Workflows: `.github/workflows/translate-i18n-base-on-english.yml` auto-runs the translation generator on `web/i18n/en-US/*.json` changes to main. `check-i18n` is a manual script (not run in CI).
|
||||
Workflows: `.github/workflows/translate-i18n-base-on-english.yml` auto-runs the translation generator on `web/i18n/en-US/*.json` changes to main. `i18n:check` is a manual script (not run in CI).
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"plans.community.includesTitle": "ميزات مجانية:",
|
||||
"plans.community.name": "مجتمع",
|
||||
"plans.community.price": "مجاني",
|
||||
"plans.community.priceTip": "",
|
||||
"plans.enterprise.btnText": "اتصل بالمبيعات",
|
||||
"plans.enterprise.description": "للمؤسسات التي تتطلب أمانًا وامتثالًا وقابلية للتوسع وتحكمًا وحلولًا مخصصة على مستوى المؤسسة",
|
||||
"plans.enterprise.features": [
|
||||
|
||||
@ -247,6 +247,7 @@
|
||||
"metadata.languageMap.no": "نرويجي",
|
||||
"metadata.languageMap.pl": "بولندي",
|
||||
"metadata.languageMap.pt": "برتغالي",
|
||||
"metadata.languageMap.ro": "روماني",
|
||||
"metadata.languageMap.ru": "روسي",
|
||||
"metadata.languageMap.sv": "سويدي",
|
||||
"metadata.languageMap.th": "تايلاندي",
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"batchAction.delete": "حذف",
|
||||
"batchAction.disable": "تعطيل",
|
||||
"batchAction.enable": "تمكين",
|
||||
"batchAction.reIndex": "إعادة الفهرسة",
|
||||
"batchAction.selected": "محدد",
|
||||
"chunkingMode.general": "عام",
|
||||
"chunkingMode.graph": "رسم بياني",
|
||||
@ -88,6 +89,7 @@
|
||||
"indexingMethod.full_text_search": "FULL TEXT",
|
||||
"indexingMethod.hybrid_search": "HYBRID",
|
||||
"indexingMethod.invertedIndex": "فهرس معكوس",
|
||||
"indexingMethod.keyword_search": "كلمة مفتاحية",
|
||||
"indexingMethod.semantic_search": "VECTOR",
|
||||
"indexingTechnique.economy": "ECO",
|
||||
"indexingTechnique.high_quality": "HQ",
|
||||
@ -154,6 +156,8 @@
|
||||
"retrieval.hybrid_search.description": "تنفيذ البحث بالنص الكامل والبحث المتجه في وقت واحد، وإعادة الترتيب لتحديد أفضل تطابق لاستعلام المستخدم. يمكن للمستخدمين اختيار تعيين الأوزان أو التكوين لنموذج إعادة الترتيب.",
|
||||
"retrieval.hybrid_search.recommend": "نوصي",
|
||||
"retrieval.hybrid_search.title": "بحث هجين",
|
||||
"retrieval.invertedIndex.description": "الفهرس المقلوب هو هيكل يُستخدم للاسترجاع الفعال. منظم حسب المصطلحات، كل مصطلح يشير إلى المستندات أو صفحات الويب التي تحتوي عليه.",
|
||||
"retrieval.invertedIndex.title": "الفهرس المعكوس",
|
||||
"retrieval.keyword_search.description": "الفهرس المعكوس هو هيكل يستخدم للاسترجاع الفعال. منظم حسب المصطلحات، يشير كل مصطلح إلى المستندات أو صفحات الويب التي تحتوي عليه.",
|
||||
"retrieval.keyword_search.title": "فهرس معكوس",
|
||||
"retrieval.semantic_search.description": "إنشاء تضمينات الاستعلام والبحث عن قطعة النص الأكثر تشابهًا مع تمثيلها المتجه.",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"category.Entertainment": "ترفيه",
|
||||
"category.HR": "الموارد البشرية",
|
||||
"category.Programming": "برمجة",
|
||||
"category.Recommended": "موصى به",
|
||||
"category.Translate": "ترجمة",
|
||||
"category.Workflow": "سير العمل",
|
||||
"category.Writing": "كتابة",
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
{
|
||||
"addToolModal.added": "أضيف",
|
||||
"addToolModal.agent.tip": "",
|
||||
"addToolModal.agent.title": "لا توجد استراتيجية وكيل متاحة",
|
||||
"addToolModal.all.tip": "",
|
||||
"addToolModal.all.title": "لا توجد أدوات متاحة",
|
||||
"addToolModal.built-in.tip": "",
|
||||
"addToolModal.built-in.title": "لا توجد أداة مضمنة متاحة",
|
||||
"addToolModal.category": "فئة",
|
||||
"addToolModal.custom.tip": "إنشاء أداة مخصصة",
|
||||
"addToolModal.custom.title": "لا توجد أداة مخصصة متاحة",
|
||||
@ -34,6 +39,7 @@
|
||||
"createTool.authMethod.type": "نوع التفويض",
|
||||
"createTool.authMethod.types.apiKeyPlaceholder": "اسم رأس HTTP لمفتاح API",
|
||||
"createTool.authMethod.types.apiValuePlaceholder": "أدخل مفتاح API",
|
||||
"createTool.authMethod.types.api_key": "مفتاح API",
|
||||
"createTool.authMethod.types.api_key_header": "رأس",
|
||||
"createTool.authMethod.types.api_key_query": "معلمة استعلام",
|
||||
"createTool.authMethod.types.none": "لا شيء",
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"blocks.assigner": "معين المتغيرات",
|
||||
"blocks.code": "كود",
|
||||
"blocks.datasource": "مصدر البيانات",
|
||||
"blocks.datasource-empty": "مصدر بيانات فارغ",
|
||||
"blocks.document-extractor": "مستخرج المستندات",
|
||||
"blocks.end": "الإخراج",
|
||||
"blocks.http-request": "طلب HTTP",
|
||||
@ -22,6 +23,7 @@
|
||||
"blocks.question-classifier": "مصنف الأسئلة",
|
||||
"blocks.start": "إدخال المستخدم",
|
||||
"blocks.template-transform": "قالب",
|
||||
"blocks.tool": "أداة",
|
||||
"blocks.trigger-plugin": "مشغل الإضافة",
|
||||
"blocks.trigger-schedule": "جدولة المشغل",
|
||||
"blocks.trigger-webhook": "مشغل الويب هوك",
|
||||
@ -32,21 +34,25 @@
|
||||
"blocksAbout.assigner": "تُستخدم عقدة تعيين المتغير لتعيين قيم للمتغيرات القابلة للكتابة (مثل متغيرات المحادثة).",
|
||||
"blocksAbout.code": "تنفيذ قطعة من كود Python أو NodeJS لتنفيذ منطق مخصص",
|
||||
"blocksAbout.datasource": "حول مصدر البيانات",
|
||||
"blocksAbout.datasource-empty": "عنصر نائب لمصدر البيانات الفارغ",
|
||||
"blocksAbout.document-extractor": "تستخدم لتحليل المستندات التي تم تحميلها إلى محتوى نصي يسهل فهمه بواسطة LLM.",
|
||||
"blocksAbout.end": "تحديد الإخراج ونوع النتيجة لسير العمل",
|
||||
"blocksAbout.http-request": "السماح بإرسال طلبات الخادم عبر بروتوكول HTTP",
|
||||
"blocksAbout.if-else": "يسمح لك بتقسيم سير العمل إلى فرعين بناءً على شروط if/else",
|
||||
"blocksAbout.iteration": "تنفيذ خطوات متعددة على كائن قائمة حتى يتم إخراج جميع النتائج.",
|
||||
"blocksAbout.iteration-start": "نقطة بدء التكرار",
|
||||
"blocksAbout.knowledge-index": "حول قاعدة المعرفة",
|
||||
"blocksAbout.knowledge-retrieval": "يسمح لك بالاستعلام عن محتوى النص المتعلق بأسئلة المستخدم من المعرفة",
|
||||
"blocksAbout.list-operator": "تستخدم لتصفية أو فرز محتوى المصفوفة.",
|
||||
"blocksAbout.llm": "استدعاء نماذج اللغة الكبيرة للإجابة على الأسئلة أو معالجة اللغة الطبيعية",
|
||||
"blocksAbout.loop": "تنفيذ حلقة من المنطق حتى يتم استيفاء شروط الإنهاء أو الوصول إلى الحد الأقصى لعدد الحلقات.",
|
||||
"blocksAbout.loop-end": "يعادل \"break\". هذه العقدة لا تحتوي على عناصر تكوين. عندما يصل جسم الحلقة إلى هذه العقدة، تنتهي الحلقة.",
|
||||
"blocksAbout.loop-start": "نقطة بدء الحلقة",
|
||||
"blocksAbout.parameter-extractor": "استخدم LLM لاستخراج المعلمات الهيكلية من اللغة الطبيعية لاستدعاء الأدوات أو طلبات HTTP.",
|
||||
"blocksAbout.question-classifier": "تحديد شروط تصنيف أسئلة المستخدم، يمكن لـ LLM تحديد كيفية تقدم المحادثة بناءً على وصف التصنيف",
|
||||
"blocksAbout.start": "تحديد المعلمات الأولية لبدء سير العمل",
|
||||
"blocksAbout.template-transform": "تحويل البيانات إلى سلسلة باستخدام بنية قالب Jinja",
|
||||
"blocksAbout.tool": "استخدم الأدوات الخارجية لتوسيع قدرات سير العمل",
|
||||
"blocksAbout.trigger-plugin": "مشغل تكامل تابع لجهة خارجية يبدأ سير العمل من أحداث النظام الأساسي الخارجي",
|
||||
"blocksAbout.trigger-schedule": "مشغل سير عمل قائم على الوقت يبدأ سير العمل وفقًا لجدول زمني",
|
||||
"blocksAbout.trigger-webhook": "يتلقى مشغل Webhook دفعات HTTP من أنظمة خارجية لتشغيل سير العمل تلقائيًا.",
|
||||
@ -507,6 +513,8 @@
|
||||
"nodes.ifElse.comparisonOperator.in": "في",
|
||||
"nodes.ifElse.comparisonOperator.is": "هو",
|
||||
"nodes.ifElse.comparisonOperator.is not": "ليس",
|
||||
"nodes.ifElse.comparisonOperator.is not null": "ليس فارغًا",
|
||||
"nodes.ifElse.comparisonOperator.is null": "فارغ",
|
||||
"nodes.ifElse.comparisonOperator.not contains": "لا يحتوي على",
|
||||
"nodes.ifElse.comparisonOperator.not empty": "ليس فارغًا",
|
||||
"nodes.ifElse.comparisonOperator.not exists": "غير موجود",
|
||||
@ -971,6 +979,8 @@
|
||||
"singleRun.startRun": "بدء التشغيل",
|
||||
"singleRun.testRun": "تشغيل اختياري",
|
||||
"singleRun.testRunIteration": "تكرار تشغيل الاختبار",
|
||||
"singleRun.testRunLoop": "حلقة اختبار التشغيل",
|
||||
"tabs.-": "افتراضي",
|
||||
"tabs.addAll": "إضافة الكل",
|
||||
"tabs.agent": "استراتيجية الوكيل",
|
||||
"tabs.allAdded": "تمت إضافة الكل",
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"plans.community.includesTitle": "Kostenlose Funktionen:",
|
||||
"plans.community.name": "Gemeinschaft",
|
||||
"plans.community.price": "Kostenlos",
|
||||
"plans.community.priceTip": "",
|
||||
"plans.enterprise.btnText": "Vertrieb kontaktieren",
|
||||
"plans.enterprise.description": "Erhalten Sie volle Fähigkeiten und Unterstützung für großangelegte, missionskritische Systeme.",
|
||||
"plans.enterprise.features": [
|
||||
|
||||
@ -247,6 +247,7 @@
|
||||
"metadata.languageMap.no": "Norwegisch",
|
||||
"metadata.languageMap.pl": "Polnisch",
|
||||
"metadata.languageMap.pt": "Portugiesisch",
|
||||
"metadata.languageMap.ro": "Rumänisch",
|
||||
"metadata.languageMap.ru": "Russisch",
|
||||
"metadata.languageMap.sv": "Schwedisch",
|
||||
"metadata.languageMap.th": "Thai",
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"batchAction.delete": "Löschen",
|
||||
"batchAction.disable": "Abschalten",
|
||||
"batchAction.enable": "Ermöglichen",
|
||||
"batchAction.reIndex": "Neu indexieren",
|
||||
"batchAction.selected": "Ausgewählt",
|
||||
"chunkingMode.general": "Allgemein",
|
||||
"chunkingMode.graph": "Graph",
|
||||
@ -88,6 +89,7 @@
|
||||
"indexingMethod.full_text_search": "VOLLTEXT",
|
||||
"indexingMethod.hybrid_search": "HYBRID",
|
||||
"indexingMethod.invertedIndex": "INVERTIERT",
|
||||
"indexingMethod.keyword_search": "SCHLÜSSELWORT",
|
||||
"indexingMethod.semantic_search": "VEKTOR",
|
||||
"indexingTechnique.economy": "ECO",
|
||||
"indexingTechnique.high_quality": "HQ",
|
||||
@ -154,6 +156,8 @@
|
||||
"retrieval.hybrid_search.description": "Führe Volltextsuche und Vektorsuchen gleichzeitig aus, ordne neu, um die beste Übereinstimmung für die Abfrage des Benutzers auszuwählen. Konfiguration des Rerank-Modell-APIs ist notwendig.",
|
||||
"retrieval.hybrid_search.recommend": "Empfehlen",
|
||||
"retrieval.hybrid_search.title": "Hybridsuche",
|
||||
"retrieval.invertedIndex.description": "Ein invertierter Index ist eine Struktur, die für eine effiziente Abrufung verwendet wird. Nach Begriffen organisiert, verweist jeder Begriff auf Dokumente oder Webseiten, die ihn enthalten.",
|
||||
"retrieval.invertedIndex.title": "Invertierter Index",
|
||||
"retrieval.keyword_search.description": "Der invertierte Index ist eine Struktur, die für einen effizienten Abruf verwendet wird. Jeder Begriff ist nach Begriffen geordnet und verweist auf Dokumente oder Webseiten, die ihn enthalten.",
|
||||
"retrieval.keyword_search.title": "Invertierter Index",
|
||||
"retrieval.semantic_search.description": "Erzeuge Abfrage-Einbettungen und suche nach dem Textstück, das seiner Vektorrepräsentation am ähnlichsten ist.",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"category.Entertainment": "Unterhaltung",
|
||||
"category.HR": "Personalwesen",
|
||||
"category.Programming": "Programmieren",
|
||||
"category.Recommended": "Empfohlen",
|
||||
"category.Translate": "Übersetzen",
|
||||
"category.Workflow": "Arbeitsablauf",
|
||||
"category.Writing": "Schreiben",
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
{
|
||||
"addToolModal.added": "zugefügt",
|
||||
"addToolModal.agent.tip": "",
|
||||
"addToolModal.agent.title": "Keine Agentenstrategie verfügbar",
|
||||
"addToolModal.all.tip": "",
|
||||
"addToolModal.all.title": "Keine Werkzeuge verfügbar",
|
||||
"addToolModal.built-in.tip": "",
|
||||
"addToolModal.built-in.title": "Kein integriertes Tool verfügbar",
|
||||
"addToolModal.category": "Kategorie",
|
||||
"addToolModal.custom.tip": "Benutzerdefiniertes Werkzeug erstellen",
|
||||
"addToolModal.custom.title": "Kein benutzerdefiniertes Werkzeug verfügbar",
|
||||
@ -34,6 +39,7 @@
|
||||
"createTool.authMethod.type": "Autorisierungstyp",
|
||||
"createTool.authMethod.types.apiKeyPlaceholder": "HTTP-Headername für API-Key",
|
||||
"createTool.authMethod.types.apiValuePlaceholder": "API-Key eingeben",
|
||||
"createTool.authMethod.types.api_key": "API-Schlüssel",
|
||||
"createTool.authMethod.types.api_key_header": "Kopfzeile",
|
||||
"createTool.authMethod.types.api_key_query": "Abfrageparameter",
|
||||
"createTool.authMethod.types.none": "Keine",
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"blocks.assigner": "Variablenzuweiser",
|
||||
"blocks.code": "Code",
|
||||
"blocks.datasource": "Datenquelle",
|
||||
"blocks.datasource-empty": "Leere Datenquelle",
|
||||
"blocks.document-extractor": "Doc Extraktor",
|
||||
"blocks.end": "Ausgabe",
|
||||
"blocks.http-request": "HTTP-Anfrage",
|
||||
@ -22,6 +23,7 @@
|
||||
"blocks.question-classifier": "Fragenklassifizierer",
|
||||
"blocks.start": "Start",
|
||||
"blocks.template-transform": "Vorlage",
|
||||
"blocks.tool": "Werkzeug",
|
||||
"blocks.trigger-plugin": "Plugin-Auslöser",
|
||||
"blocks.trigger-schedule": "Zeitplan-Auslöser",
|
||||
"blocks.trigger-webhook": "Webhook-Auslöser",
|
||||
@ -32,21 +34,25 @@
|
||||
"blocksAbout.assigner": "Der Variablenzuweisungsknoten wird verwendet, um beschreibbaren Variablen (wie Gesprächsvariablen) Werte zuzuweisen.",
|
||||
"blocksAbout.code": "Ein Stück Python- oder NodeJS-Code ausführen, um benutzerdefinierte Logik zu implementieren",
|
||||
"blocksAbout.datasource": "Datenquelle Über",
|
||||
"blocksAbout.datasource-empty": "Platzhalter für leere Datenquelle",
|
||||
"blocksAbout.document-extractor": "Wird verwendet, um hochgeladene Dokumente in Textinhalte zu analysieren, die für LLM leicht verständlich sind.",
|
||||
"blocksAbout.end": "Definieren Sie die Ausgabe und den Ergebnistyp eines Workflows",
|
||||
"blocksAbout.http-request": "Ermöglichen, dass Serveranforderungen über das HTTP-Protokoll gesendet werden",
|
||||
"blocksAbout.if-else": "Ermöglicht das Aufteilen des Workflows in zwei Zweige basierend auf if/else-Bedingungen",
|
||||
"blocksAbout.iteration": "Mehrere Schritte an einem Listenobjekt ausführen, bis alle Ergebnisse ausgegeben wurden.",
|
||||
"blocksAbout.iteration-start": "Startknoten der Iteration",
|
||||
"blocksAbout.knowledge-index": "Wissensdatenbank Über",
|
||||
"blocksAbout.knowledge-retrieval": "Ermöglicht das Abfragen von Textinhalten, die sich auf Benutzerfragen aus der Wissensdatenbank beziehen",
|
||||
"blocksAbout.list-operator": "Wird verwendet, um Array-Inhalte zu filtern oder zu sortieren.",
|
||||
"blocksAbout.llm": "Große Sprachmodelle aufrufen, um Fragen zu beantworten oder natürliche Sprache zu verarbeiten",
|
||||
"blocksAbout.loop": "Führen Sie eine Schleife aus, bis die Abschlussbedingungen erfüllt sind oder die maximalen Schleifenanzahl erreicht ist.",
|
||||
"blocksAbout.loop-end": "Entspricht \"break\". Dieser Knoten hat keine Konfigurationselemente. Wenn der Schleifenrumpf diesen Knoten erreicht, wird die Schleife beendet.",
|
||||
"blocksAbout.loop-start": "Schleifenstart-Knoten",
|
||||
"blocksAbout.parameter-extractor": "Verwenden Sie LLM, um strukturierte Parameter aus natürlicher Sprache für Werkzeugaufrufe oder HTTP-Anfragen zu extrahieren.",
|
||||
"blocksAbout.question-classifier": "Definieren Sie die Klassifizierungsbedingungen von Benutzerfragen, LLM kann basierend auf der Klassifikationsbeschreibung festlegen, wie die Konversation fortschreitet",
|
||||
"blocksAbout.start": "Definieren Sie die Anfangsparameter zum Starten eines Workflows",
|
||||
"blocksAbout.template-transform": "Daten in Zeichenfolgen mit Jinja-Vorlagensyntax umwandeln",
|
||||
"blocksAbout.tool": "Verwenden Sie externe Tools, um die Workflow-Funktionen zu erweitern",
|
||||
"blocksAbout.trigger-plugin": "Auslöser für die Integration von Drittanbietern, der Workflows anhand von Ereignissen externer Plattformen startet",
|
||||
"blocksAbout.trigger-schedule": "Zeitbasierter Workflow-Auslöser, der Workflows nach einem Zeitplan startet",
|
||||
"blocksAbout.trigger-webhook": "Webhook-Trigger empfängt HTTP-Pushes von Drittanbietersystemen, um Workflows automatisch auszulösen.",
|
||||
@ -507,6 +513,8 @@
|
||||
"nodes.ifElse.comparisonOperator.in": "in",
|
||||
"nodes.ifElse.comparisonOperator.is": "ist",
|
||||
"nodes.ifElse.comparisonOperator.is not": "ist nicht",
|
||||
"nodes.ifElse.comparisonOperator.is not null": "ist nicht null",
|
||||
"nodes.ifElse.comparisonOperator.is null": "ist null",
|
||||
"nodes.ifElse.comparisonOperator.not contains": "enthält nicht",
|
||||
"nodes.ifElse.comparisonOperator.not empty": "ist nicht leer",
|
||||
"nodes.ifElse.comparisonOperator.not exists": "existiert nicht",
|
||||
@ -971,6 +979,8 @@
|
||||
"singleRun.startRun": "Lauf starten",
|
||||
"singleRun.testRun": "Testlauf ",
|
||||
"singleRun.testRunIteration": "Testlaufiteration",
|
||||
"singleRun.testRunLoop": "Testdurchlauf-Schleife",
|
||||
"tabs.-": "Standard",
|
||||
"tabs.addAll": "Alles hinzufügen",
|
||||
"tabs.agent": "Agenten-Strategie",
|
||||
"tabs.allAdded": "Alle hinzugefügt",
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"plans.community.includesTitle": "Características gratuitas:",
|
||||
"plans.community.name": "Comunidad",
|
||||
"plans.community.price": "Gratis",
|
||||
"plans.community.priceTip": "",
|
||||
"plans.enterprise.btnText": "Contactar ventas",
|
||||
"plans.enterprise.description": "Obtén capacidades completas y soporte para sistemas críticos a gran escala.",
|
||||
"plans.enterprise.features": [
|
||||
|
||||
@ -247,6 +247,7 @@
|
||||
"metadata.languageMap.no": "Noruego",
|
||||
"metadata.languageMap.pl": "Polaco",
|
||||
"metadata.languageMap.pt": "Portugués",
|
||||
"metadata.languageMap.ro": "Rumano",
|
||||
"metadata.languageMap.ru": "Ruso",
|
||||
"metadata.languageMap.sv": "Sueco",
|
||||
"metadata.languageMap.th": "Tailandés",
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"batchAction.delete": "Borrar",
|
||||
"batchAction.disable": "Inutilizar",
|
||||
"batchAction.enable": "Habilitar",
|
||||
"batchAction.reIndex": "Reindexar",
|
||||
"batchAction.selected": "Seleccionado",
|
||||
"chunkingMode.general": "General",
|
||||
"chunkingMode.graph": "gráfico",
|
||||
@ -88,6 +89,7 @@
|
||||
"indexingMethod.full_text_search": "TEXTO COMPLETO",
|
||||
"indexingMethod.hybrid_search": "HÍBRIDO",
|
||||
"indexingMethod.invertedIndex": "INVERTIDO",
|
||||
"indexingMethod.keyword_search": "PALABRA CLAVE",
|
||||
"indexingMethod.semantic_search": "VECTOR",
|
||||
"indexingTechnique.economy": "ECO",
|
||||
"indexingTechnique.high_quality": "AC",
|
||||
@ -154,6 +156,8 @@
|
||||
"retrieval.hybrid_search.description": "Ejecuta búsquedas de texto completo y búsquedas vectoriales simultáneamente, reordena para seleccionar la mejor coincidencia para la consulta del usuario. Es necesaria la configuración de las API del modelo de reordenamiento.",
|
||||
"retrieval.hybrid_search.recommend": "Recomendar",
|
||||
"retrieval.hybrid_search.title": "Búsqueda Híbrida",
|
||||
"retrieval.invertedIndex.description": "El índice invertido es una estructura utilizada para la recuperación eficiente. Organizado por términos, cada término apunta a documentos o páginas web que lo contienen.",
|
||||
"retrieval.invertedIndex.title": "Índice invertido",
|
||||
"retrieval.keyword_search.description": "El índice invertido es una estructura utilizada para una recuperación eficiente. Organizado por términos, cada término apunta a documentos o páginas web que lo contienen.",
|
||||
"retrieval.keyword_search.title": "Índice invertido",
|
||||
"retrieval.semantic_search.description": "Genera incrustaciones de consulta y busca el fragmento de texto más similar a su representación vectorial.",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"category.Entertainment": "Entretenimiento",
|
||||
"category.HR": "Recursos Humanos",
|
||||
"category.Programming": "Programación",
|
||||
"category.Recommended": "recomendado",
|
||||
"category.Translate": "Traducción",
|
||||
"category.Workflow": "Flujo de trabajo",
|
||||
"category.Writing": "Escritura",
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
{
|
||||
"addToolModal.added": "agregada",
|
||||
"addToolModal.agent.tip": "",
|
||||
"addToolModal.agent.title": "No hay estrategia de agente disponible",
|
||||
"addToolModal.all.tip": "",
|
||||
"addToolModal.all.title": "No hay herramientas disponibles",
|
||||
"addToolModal.built-in.tip": "",
|
||||
"addToolModal.built-in.title": "No hay herramienta integrada disponible",
|
||||
"addToolModal.category": "categoría",
|
||||
"addToolModal.custom.tip": "Crear una herramienta personalizada",
|
||||
"addToolModal.custom.title": "No hay herramienta personalizada disponible",
|
||||
@ -34,6 +39,7 @@
|
||||
"createTool.authMethod.type": "Tipo de Autorización",
|
||||
"createTool.authMethod.types.apiKeyPlaceholder": "Nombre del encabezado HTTP para la Clave API",
|
||||
"createTool.authMethod.types.apiValuePlaceholder": "Ingresa la Clave API",
|
||||
"createTool.authMethod.types.api_key": "Clave de API",
|
||||
"createTool.authMethod.types.api_key_header": "Encabezado",
|
||||
"createTool.authMethod.types.api_key_query": "Parámetro de consulta",
|
||||
"createTool.authMethod.types.none": "Ninguno",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user