Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu 2025-12-30 09:34:30 +08:00
commit bf6a2c22eb
226 changed files with 49186 additions and 467 deletions

View File

@ -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'

View File

@ -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

View File

@ -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()

View File

@ -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))

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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 ====================

View File

@ -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

View File

@ -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')

View 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',
}),
])
})
})
})

View File

@ -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 {

View File

@ -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)}

View 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>
)
}

View 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>
)
}

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

View File

@ -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()
})
})
})

View File

@ -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

View File

@ -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),
)
})
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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()
})
})
})
})

View File

@ -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)
})
})
})
})

View File

@ -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()
})
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})

View 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 &amp; 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()
})
})

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

View File

@ -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 })
},
)
})
})

View File

@ -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

View File

@ -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

View 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()
})
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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()
})
})
})

View File

@ -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')
})
})
})

View File

@ -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' }))
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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')
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View 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()
})
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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'

View 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)
})
})
})

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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()
})
})
})

View File

@ -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)

View File

@ -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"

View File

@ -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>
)
}

View File

@ -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).

View File

@ -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": [

View File

@ -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": "تايلاندي",

View File

@ -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": "إنشاء تضمينات الاستعلام والبحث عن قطعة النص الأكثر تشابهًا مع تمثيلها المتجه.",

View File

@ -12,6 +12,7 @@
"category.Entertainment": "ترفيه",
"category.HR": "الموارد البشرية",
"category.Programming": "برمجة",
"category.Recommended": "موصى به",
"category.Translate": "ترجمة",
"category.Workflow": "سير العمل",
"category.Writing": "كتابة",

View File

@ -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": "لا شيء",

View File

@ -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": "تمت إضافة الكل",

View File

@ -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": [

View File

@ -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",

View File

@ -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.",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": [

View File

@ -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",

View File

@ -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.",

View File

@ -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",

View File

@ -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