mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 11:04:01 +00:00
Compare commits
17 Commits
feat/marke
...
test/tool-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3849e444bf | ||
|
|
1afc354d97 | ||
|
|
0e36aa9c67 | ||
|
|
c36de51771 | ||
|
|
b0b4cac03f | ||
|
|
18f14c04dc | ||
|
|
14251b249d | ||
|
|
1819bd72ef | ||
|
|
7dabc03a08 | ||
|
|
1a050c9f86 | ||
|
|
7fb6e0cdfe | ||
|
|
e0fcf33979 | ||
|
|
898e09264b | ||
|
|
4ac461d882 | ||
|
|
fa763216d0 | ||
|
|
d546210040 | ||
|
|
4e0a7a7f9e |
@@ -34,6 +34,7 @@ from .dataset import (
|
||||
metadata,
|
||||
segment,
|
||||
)
|
||||
from .dataset.rag_pipeline import rag_pipeline_workflow
|
||||
from .end_user import end_user
|
||||
from .workspace import models
|
||||
|
||||
@@ -53,6 +54,7 @@ __all__ = [
|
||||
"message",
|
||||
"metadata",
|
||||
"models",
|
||||
"rag_pipeline_workflow",
|
||||
"segment",
|
||||
"site",
|
||||
"workflow",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import string
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
@@ -12,6 +10,7 @@ from controllers.common.errors import FilenameNotExistsError, NoFileUploadedErro
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.dataset.error import PipelineRunError
|
||||
from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file
|
||||
from controllers.service_api.wraps import DatasetApiResource
|
||||
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -41,7 +40,7 @@ register_schema_model(service_api_ns, DatasourceNodeRunPayload)
|
||||
register_schema_model(service_api_ns, PipelineRunApiEntity)
|
||||
|
||||
|
||||
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/datasource-plugins")
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource-plugins")
|
||||
class DatasourcePluginsApi(DatasetApiResource):
|
||||
"""Resource for datasource plugins."""
|
||||
|
||||
@@ -76,7 +75,7 @@ class DatasourcePluginsApi(DatasetApiResource):
|
||||
return datasource_plugins, 200
|
||||
|
||||
|
||||
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/datasource/nodes/{string:node_id}/run")
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run")
|
||||
class DatasourceNodeRunApi(DatasetApiResource):
|
||||
"""Resource for datasource node run."""
|
||||
|
||||
@@ -131,7 +130,7 @@ class DatasourceNodeRunApi(DatasetApiResource):
|
||||
)
|
||||
|
||||
|
||||
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/run")
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/run")
|
||||
class PipelineRunApi(DatasetApiResource):
|
||||
"""Resource for datasource node run."""
|
||||
|
||||
@@ -232,12 +231,4 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
return {
|
||||
"id": upload_file.id,
|
||||
"name": upload_file.name,
|
||||
"size": upload_file.size,
|
||||
"extension": upload_file.extension,
|
||||
"mime_type": upload_file.mime_type,
|
||||
"created_by": upload_file.created_by,
|
||||
"created_at": upload_file.created_at,
|
||||
}, 201
|
||||
return serialize_upload_file(upload_file), 201
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Serialization helpers for Service API knowledge pipeline endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.model import UploadFile
|
||||
|
||||
|
||||
def serialize_upload_file(upload_file: UploadFile) -> dict[str, Any]:
|
||||
return {
|
||||
"id": upload_file.id,
|
||||
"name": upload_file.name,
|
||||
"size": upload_file.size,
|
||||
"extension": upload_file.extension,
|
||||
"mime_type": upload_file.mime_type,
|
||||
"created_by": upload_file.created_by,
|
||||
"created_at": upload_file.created_at.isoformat() if upload_file.created_at else None,
|
||||
}
|
||||
@@ -217,6 +217,8 @@ def validate_dataset_token(view: Callable[Concatenate[T, P], R] | None = None):
|
||||
def decorator(view: Callable[Concatenate[T, P], R]):
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
api_token = validate_and_get_api_token("dataset")
|
||||
|
||||
# get url path dataset_id from positional args or kwargs
|
||||
# Flask passes URL path parameters as positional arguments
|
||||
dataset_id = None
|
||||
@@ -253,12 +255,18 @@ def validate_dataset_token(view: Callable[Concatenate[T, P], R] | None = None):
|
||||
# Validate dataset if dataset_id is provided
|
||||
if dataset_id:
|
||||
dataset_id = str(dataset_id)
|
||||
dataset = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
dataset = (
|
||||
db.session.query(Dataset)
|
||||
.where(
|
||||
Dataset.id == dataset_id,
|
||||
Dataset.tenant_id == api_token.tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
if not dataset.enable_api:
|
||||
raise Forbidden("Dataset api access is not enabled.")
|
||||
api_token = validate_and_get_api_token("dataset")
|
||||
tenant_account_join = (
|
||||
db.session.query(Tenant, TenantAccountJoin)
|
||||
.where(Tenant.id == api_token.tenant_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Generator
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from models.model import File
|
||||
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -171,7 +171,7 @@ class Tool(ABC):
|
||||
def create_file_message(self, file: File) -> ToolInvokeMessage:
|
||||
return ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.FILE,
|
||||
message=ToolInvokeMessage.FileMessage(),
|
||||
message=ToolInvokeMessage.FileMessage(file_marker="file_marker"),
|
||||
meta={"file": file},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar, cast
|
||||
from typing import ParamSpec, TypeVar, cast
|
||||
|
||||
from opentelemetry.trace import get_tracer
|
||||
|
||||
@@ -8,7 +8,8 @@ from configs import dify_config
|
||||
from extensions.otel.decorators.handler import SpanHandler
|
||||
from extensions.otel.runtime import is_instrument_flag_enabled
|
||||
|
||||
T = TypeVar("T", bound=Callable[..., Any])
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
_HANDLER_INSTANCES: dict[type[SpanHandler], SpanHandler] = {SpanHandler: SpanHandler()}
|
||||
|
||||
@@ -20,7 +21,7 @@ def _get_handler_instance(handler_class: type[SpanHandler]) -> SpanHandler:
|
||||
return _HANDLER_INSTANCES[handler_class]
|
||||
|
||||
|
||||
def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T], T]:
|
||||
def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""
|
||||
Decorator that traces a function with an OpenTelemetry span.
|
||||
|
||||
@@ -30,9 +31,9 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
|
||||
:param handler_class: Optional handler class to use for this span. If None, uses the default SpanHandler.
|
||||
"""
|
||||
|
||||
def decorator(func: T) -> T:
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -46,6 +47,6 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
return cast(T, wrapper)
|
||||
return cast(Callable[P, R], wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import inspect
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from opentelemetry.trace import SpanKind, Status, StatusCode
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
class SpanHandler:
|
||||
"""
|
||||
@@ -31,9 +33,9 @@ class SpanHandler:
|
||||
|
||||
def _extract_arguments(
|
||||
self,
|
||||
wrapped: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any],
|
||||
wrapped: Callable[..., R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Extract function arguments using inspect.signature.
|
||||
@@ -62,10 +64,10 @@ class SpanHandler:
|
||||
def wrapper(
|
||||
self,
|
||||
tracer: Any,
|
||||
wrapped: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any],
|
||||
) -> Any:
|
||||
wrapped: Callable[..., R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> R:
|
||||
"""
|
||||
Fully control the wrapper behavior.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from opentelemetry.trace import SpanKind, Status, StatusCode
|
||||
from opentelemetry.util.types import AttributeValue
|
||||
@@ -12,16 +12,19 @@ from models.model import Account
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
class AppGenerateHandler(SpanHandler):
|
||||
"""Span handler for ``AppGenerateService.generate``."""
|
||||
|
||||
def wrapper(
|
||||
self,
|
||||
tracer: Any,
|
||||
wrapped: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any],
|
||||
) -> Any:
|
||||
wrapped: Callable[..., R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> R:
|
||||
try:
|
||||
arguments = self._extract_arguments(wrapped, args, kwargs)
|
||||
if not arguments:
|
||||
|
||||
@@ -1225,7 +1225,12 @@ class TenantService:
|
||||
|
||||
@staticmethod
|
||||
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
|
||||
"""Remove member from tenant"""
|
||||
"""Remove member from tenant.
|
||||
|
||||
If the removed member has ``AccountStatus.PENDING`` (invited but never
|
||||
activated) and no remaining workspace memberships, the orphaned account
|
||||
record is deleted as well.
|
||||
"""
|
||||
if operator.id == account.id:
|
||||
raise CannotOperateSelfError("Cannot operate self.")
|
||||
|
||||
@@ -1235,9 +1240,31 @@ class TenantService:
|
||||
if not ta:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
# Capture identifiers before any deletions; attribute access on the ORM
|
||||
# object may fail after commit() expires the instance.
|
||||
account_id = account.id
|
||||
account_email = account.email
|
||||
|
||||
db.session.delete(ta)
|
||||
|
||||
# Clean up orphaned pending accounts (invited but never activated)
|
||||
should_delete_account = False
|
||||
if account.status == AccountStatus.PENDING:
|
||||
# autoflush flushes ta deletion before this query, so 0 means no remaining joins
|
||||
remaining_joins = db.session.query(TenantAccountJoin).filter_by(account_id=account_id).count()
|
||||
if remaining_joins == 0:
|
||||
db.session.delete(account)
|
||||
should_delete_account = True
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if should_delete_account:
|
||||
logger.info(
|
||||
"Deleted orphaned pending account: account_id=%s, email=%s",
|
||||
account_id,
|
||||
account_email,
|
||||
)
|
||||
|
||||
if dify_config.BILLING_ENABLED:
|
||||
BillingService.clean_billing_info_cache(tenant.id)
|
||||
|
||||
@@ -1245,13 +1272,13 @@ class TenantService:
|
||||
from services.enterprise.account_deletion_sync import sync_workspace_member_removal
|
||||
|
||||
sync_success = sync_workspace_member_removal(
|
||||
workspace_id=tenant.id, member_id=account.id, source="workspace_member_removed"
|
||||
workspace_id=tenant.id, member_id=account_id, source="workspace_member_removed"
|
||||
)
|
||||
if not sync_success:
|
||||
logger.warning(
|
||||
"Enterprise workspace member removal sync failed: workspace_id=%s, member_id=%s",
|
||||
tenant.id,
|
||||
account.id,
|
||||
account_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1329,10 +1329,24 @@ class RagPipelineService:
|
||||
"""
|
||||
Get datasource plugins
|
||||
"""
|
||||
dataset: Dataset | None = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
dataset: Dataset | None = (
|
||||
db.session.query(Dataset)
|
||||
.where(
|
||||
Dataset.id == dataset_id,
|
||||
Dataset.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not dataset:
|
||||
raise ValueError("Dataset not found")
|
||||
pipeline: Pipeline | None = db.session.query(Pipeline).where(Pipeline.id == dataset.pipeline_id).first()
|
||||
pipeline: Pipeline | None = (
|
||||
db.session.query(Pipeline)
|
||||
.where(
|
||||
Pipeline.id == dataset.pipeline_id,
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not pipeline:
|
||||
raise ValueError("Pipeline not found")
|
||||
|
||||
@@ -1413,10 +1427,24 @@ class RagPipelineService:
|
||||
"""
|
||||
Get pipeline
|
||||
"""
|
||||
dataset: Dataset | None = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
dataset: Dataset | None = (
|
||||
db.session.query(Dataset)
|
||||
.where(
|
||||
Dataset.id == dataset_id,
|
||||
Dataset.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not dataset:
|
||||
raise ValueError("Dataset not found")
|
||||
pipeline: Pipeline | None = db.session.query(Pipeline).where(Pipeline.id == dataset.pipeline_id).first()
|
||||
pipeline: Pipeline | None = (
|
||||
db.session.query(Pipeline)
|
||||
.where(
|
||||
Pipeline.id == dataset.pipeline_id,
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not pipeline:
|
||||
raise ValueError("Pipeline not found")
|
||||
return pipeline
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask_login import current_user
|
||||
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import TenantService
|
||||
@@ -53,7 +54,12 @@ class WorkspaceService:
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
paid_pool = CreditPoolService.get_pool(tenant_id=tenant.id, pool_type="paid")
|
||||
if paid_pool:
|
||||
# if the tenant is not on the sandbox plan and the paid pool is not full, use the paid pool
|
||||
if (
|
||||
feature.billing.subscription.plan != CloudPlan.SANDBOX
|
||||
and paid_pool is not None
|
||||
and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used)
|
||||
):
|
||||
tenant_info["trial_credits"] = paid_pool.quota_limit
|
||||
tenant_info["trial_credits_used"] = paid_pool.quota_used
|
||||
else:
|
||||
|
||||
@@ -23,40 +23,40 @@ def clean_notion_document_task(document_ids: list[str], dataset_id: str):
|
||||
"""
|
||||
logger.info(click.style(f"Start clean document when import form notion document deleted: {dataset_id}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
total_index_node_ids = []
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
try:
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
|
||||
if not dataset:
|
||||
raise Exception("Document has no dataset")
|
||||
index_type = dataset.doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
if not dataset:
|
||||
raise Exception("Document has no dataset")
|
||||
index_type = dataset.doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
|
||||
document_delete_stmt = delete(Document).where(Document.id.in_(document_ids))
|
||||
session.execute(document_delete_stmt)
|
||||
document_delete_stmt = delete(Document).where(Document.id.in_(document_ids))
|
||||
session.execute(document_delete_stmt)
|
||||
|
||||
for document_id in document_ids:
|
||||
segments = session.scalars(
|
||||
select(DocumentSegment).where(DocumentSegment.document_id == document_id)
|
||||
).all()
|
||||
index_node_ids = [segment.index_node_id for segment in segments]
|
||||
for document_id in document_ids:
|
||||
segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all()
|
||||
total_index_node_ids.extend([segment.index_node_id for segment in segments])
|
||||
|
||||
index_processor.clean(
|
||||
dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True
|
||||
)
|
||||
segment_ids = [segment.id for segment in segments]
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids))
|
||||
session.execute(segment_delete_stmt)
|
||||
session.commit()
|
||||
end_at = time.perf_counter()
|
||||
logger.info(
|
||||
click.style(
|
||||
"Clean document when import form notion document deleted end :: {} latency: {}".format(
|
||||
dataset_id, end_at - start_at
|
||||
),
|
||||
fg="green",
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if dataset:
|
||||
index_processor.clean(
|
||||
dataset, total_index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Cleaned document when import form notion document deleted failed")
|
||||
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id.in_(document_ids))
|
||||
session.execute(segment_delete_stmt)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logger.info(
|
||||
click.style(
|
||||
"Clean document when import form notion document deleted end :: {} latency: {}".format(
|
||||
dataset_id, end_at - start_at
|
||||
),
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str):
|
||||
"""
|
||||
logger.info(click.style(f"Start sync document: {document_id}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
tenant_id = None
|
||||
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
document = session.query(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).first()
|
||||
@@ -35,94 +36,120 @@ def document_indexing_sync_task(dataset_id: str, document_id: str):
|
||||
logger.info(click.style(f"Document not found: {document_id}", fg="red"))
|
||||
return
|
||||
|
||||
if document.indexing_status == "parsing":
|
||||
logger.info(click.style(f"Document {document_id} is already being processed, skipping", fg="yellow"))
|
||||
return
|
||||
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise Exception("Dataset not found")
|
||||
|
||||
data_source_info = document.data_source_info_dict
|
||||
if document.data_source_type == "notion_import":
|
||||
if (
|
||||
not data_source_info
|
||||
or "notion_page_id" not in data_source_info
|
||||
or "notion_workspace_id" not in data_source_info
|
||||
):
|
||||
raise ValueError("no notion page found")
|
||||
workspace_id = data_source_info["notion_workspace_id"]
|
||||
page_id = data_source_info["notion_page_id"]
|
||||
page_type = data_source_info["type"]
|
||||
page_edited_time = data_source_info["last_edited_time"]
|
||||
credential_id = data_source_info.get("credential_id")
|
||||
if document.data_source_type != "notion_import":
|
||||
logger.info(click.style(f"Document {document_id} is not a notion_import, skipping", fg="yellow"))
|
||||
return
|
||||
|
||||
# Get credentials from datasource provider
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=document.tenant_id,
|
||||
credential_id=credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
if (
|
||||
not data_source_info
|
||||
or "notion_page_id" not in data_source_info
|
||||
or "notion_workspace_id" not in data_source_info
|
||||
):
|
||||
raise ValueError("no notion page found")
|
||||
|
||||
if not credential:
|
||||
logger.error(
|
||||
"Datasource credential not found for document %s, tenant_id: %s, credential_id: %s",
|
||||
document_id,
|
||||
document.tenant_id,
|
||||
credential_id,
|
||||
)
|
||||
workspace_id = data_source_info["notion_workspace_id"]
|
||||
page_id = data_source_info["notion_page_id"]
|
||||
page_type = data_source_info["type"]
|
||||
page_edited_time = data_source_info["last_edited_time"]
|
||||
credential_id = data_source_info.get("credential_id")
|
||||
tenant_id = document.tenant_id
|
||||
index_type = document.doc_form
|
||||
|
||||
segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all()
|
||||
index_node_ids = [segment.index_node_id for segment in segments]
|
||||
|
||||
# Get credentials from datasource provider
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=tenant_id,
|
||||
credential_id=credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
|
||||
if not credential:
|
||||
logger.error(
|
||||
"Datasource credential not found for document %s, tenant_id: %s, credential_id: %s",
|
||||
document_id,
|
||||
tenant_id,
|
||||
credential_id,
|
||||
)
|
||||
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
document = session.query(Document).filter_by(id=document_id).first()
|
||||
if document:
|
||||
document.indexing_status = "error"
|
||||
document.error = "Datasource credential not found. Please reconnect your Notion workspace."
|
||||
document.stopped_at = naive_utc_now()
|
||||
return
|
||||
return
|
||||
|
||||
loader = NotionExtractor(
|
||||
notion_workspace_id=workspace_id,
|
||||
notion_obj_id=page_id,
|
||||
notion_page_type=page_type,
|
||||
notion_access_token=credential.get("integration_secret"),
|
||||
tenant_id=document.tenant_id,
|
||||
)
|
||||
loader = NotionExtractor(
|
||||
notion_workspace_id=workspace_id,
|
||||
notion_obj_id=page_id,
|
||||
notion_page_type=page_type,
|
||||
notion_access_token=credential.get("integration_secret"),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
last_edited_time = loader.get_notion_last_edited_time()
|
||||
last_edited_time = loader.get_notion_last_edited_time()
|
||||
if last_edited_time == page_edited_time:
|
||||
logger.info(click.style(f"Document {document_id} content unchanged, skipping sync", fg="yellow"))
|
||||
return
|
||||
|
||||
# check the page is updated
|
||||
if last_edited_time != page_edited_time:
|
||||
document.indexing_status = "parsing"
|
||||
document.processing_started_at = naive_utc_now()
|
||||
logger.info(click.style(f"Document {document_id} content changed, starting sync", fg="green"))
|
||||
|
||||
# delete all document segment and index
|
||||
try:
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise Exception("Dataset not found")
|
||||
index_type = document.doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
try:
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
with session_factory.create_session() as session:
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if dataset:
|
||||
index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True)
|
||||
logger.info(click.style(f"Cleaned vector index for document {document_id}", fg="green"))
|
||||
except Exception:
|
||||
logger.exception("Failed to clean vector index for document %s", document_id)
|
||||
|
||||
segments = session.scalars(
|
||||
select(DocumentSegment).where(DocumentSegment.document_id == document_id)
|
||||
).all()
|
||||
index_node_ids = [segment.index_node_id for segment in segments]
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
document = session.query(Document).filter_by(id=document_id).first()
|
||||
if not document:
|
||||
logger.warning(click.style(f"Document {document_id} not found during sync", fg="yellow"))
|
||||
return
|
||||
|
||||
# delete from vector index
|
||||
index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True)
|
||||
data_source_info = document.data_source_info_dict
|
||||
data_source_info["last_edited_time"] = last_edited_time
|
||||
document.data_source_info = data_source_info
|
||||
|
||||
segment_ids = [segment.id for segment in segments]
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids))
|
||||
session.execute(segment_delete_stmt)
|
||||
document.indexing_status = "parsing"
|
||||
document.processing_started_at = naive_utc_now()
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logger.info(
|
||||
click.style(
|
||||
"Cleaned document when document update data source or process rule: {} latency: {}".format(
|
||||
document_id, end_at - start_at
|
||||
),
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Cleaned document when document update data source or process rule failed")
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id == document_id)
|
||||
session.execute(segment_delete_stmt)
|
||||
|
||||
try:
|
||||
indexing_runner = IndexingRunner()
|
||||
indexing_runner.run([document])
|
||||
end_at = time.perf_counter()
|
||||
logger.info(click.style(f"update document: {document.id} latency: {end_at - start_at}", fg="green"))
|
||||
except DocumentIsPausedError as ex:
|
||||
logger.info(click.style(str(ex), fg="yellow"))
|
||||
except Exception:
|
||||
logger.exception("document_indexing_sync_task failed, document_id: %s", document_id)
|
||||
logger.info(click.style(f"Deleted segments for document {document_id}", fg="green"))
|
||||
|
||||
try:
|
||||
indexing_runner = IndexingRunner()
|
||||
with session_factory.create_session() as session:
|
||||
document = session.query(Document).filter_by(id=document_id).first()
|
||||
if document:
|
||||
indexing_runner.run([document])
|
||||
end_at = time.perf_counter()
|
||||
logger.info(click.style(f"Sync completed for document {document_id} latency: {end_at - start_at}", fg="green"))
|
||||
except DocumentIsPausedError as ex:
|
||||
logger.info(click.style(str(ex), fg="yellow"))
|
||||
except Exception as e:
|
||||
logger.exception("document_indexing_sync_task failed for document_id: %s", document_id)
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
document = session.query(Document).filter_by(id=document_id).first()
|
||||
if document:
|
||||
document.indexing_status = "error"
|
||||
document.error = str(e)
|
||||
document.stopped_at = naive_utc_now()
|
||||
|
||||
@@ -153,8 +153,7 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task
|
||||
clean_notion_document_task(document_ids, dataset.id)
|
||||
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id.in_(document_ids)).count() == 0
|
||||
# Verify segments are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id.in_(document_ids))
|
||||
@@ -162,9 +161,9 @@ class TestCleanNotionDocumentTask:
|
||||
== 0
|
||||
)
|
||||
|
||||
# Verify index processor was called for each document
|
||||
# Verify index processor was called
|
||||
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
assert mock_processor.clean.call_count == len(document_ids)
|
||||
mock_processor.clean.assert_called_once()
|
||||
|
||||
# This test successfully verifies:
|
||||
# 1. Document records are properly deleted from the database
|
||||
@@ -186,12 +185,12 @@ class TestCleanNotionDocumentTask:
|
||||
non_existent_dataset_id = str(uuid.uuid4())
|
||||
document_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
|
||||
# Execute cleanup task with non-existent dataset
|
||||
clean_notion_document_task(document_ids, non_existent_dataset_id)
|
||||
# Execute cleanup task with non-existent dataset - expect exception
|
||||
with pytest.raises(Exception, match="Document has no dataset"):
|
||||
clean_notion_document_task(document_ids, non_existent_dataset_id)
|
||||
|
||||
# Verify that the index processor was not called
|
||||
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
mock_processor.clean.assert_not_called()
|
||||
# Verify that the index processor factory was not used
|
||||
mock_index_processor_factory.return_value.init_index_processor.assert_not_called()
|
||||
|
||||
def test_clean_notion_document_task_empty_document_list(
|
||||
self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies
|
||||
@@ -229,9 +228,13 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task with empty document list
|
||||
clean_notion_document_task([], dataset.id)
|
||||
|
||||
# Verify that the index processor was not called
|
||||
# Verify that the index processor was called once with empty node list
|
||||
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
mock_processor.clean.assert_not_called()
|
||||
assert mock_processor.clean.call_count == 1
|
||||
args, kwargs = mock_processor.clean.call_args
|
||||
# args: (dataset, total_index_node_ids)
|
||||
assert isinstance(args[0], Dataset)
|
||||
assert args[1] == []
|
||||
|
||||
def test_clean_notion_document_task_with_different_index_types(
|
||||
self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies
|
||||
@@ -315,8 +318,7 @@ class TestCleanNotionDocumentTask:
|
||||
# Note: This test successfully verifies cleanup with different document types.
|
||||
# The task properly handles various index types and document configurations.
|
||||
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
|
||||
# Verify segments are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id == document.id)
|
||||
@@ -404,8 +406,7 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
|
||||
# Verify segments are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).count()
|
||||
== 0
|
||||
@@ -508,8 +509,7 @@ class TestCleanNotionDocumentTask:
|
||||
|
||||
clean_notion_document_task(documents_to_clean, dataset.id)
|
||||
|
||||
# Verify only specified documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id.in_(documents_to_clean)).count() == 0
|
||||
# Verify only specified documents' segments are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id.in_(documents_to_clean))
|
||||
@@ -697,11 +697,12 @@ class TestCleanNotionDocumentTask:
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# Mock index processor to raise an exception
|
||||
mock_index_processor = mock_index_processor_factory.init_index_processor.return_value
|
||||
mock_index_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
mock_index_processor.clean.side_effect = Exception("Index processor error")
|
||||
|
||||
# Execute cleanup task - it should handle the exception gracefully
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
# Execute cleanup task - current implementation propagates the exception
|
||||
with pytest.raises(Exception, match="Index processor error"):
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
|
||||
# Note: This test demonstrates the task's error handling capability.
|
||||
# Even with external service errors, the database operations complete successfully.
|
||||
@@ -803,8 +804,7 @@ class TestCleanNotionDocumentTask:
|
||||
all_document_ids = [doc.id for doc in documents]
|
||||
clean_notion_document_task(all_document_ids, dataset.id)
|
||||
|
||||
# Verify all documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.dataset_id == dataset.id).count() == 0
|
||||
# Verify all segments are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset.id).count()
|
||||
== 0
|
||||
@@ -914,8 +914,7 @@ class TestCleanNotionDocumentTask:
|
||||
|
||||
clean_notion_document_task([target_document.id], target_dataset.id)
|
||||
|
||||
# Verify only documents from target dataset are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == target_document.id).count() == 0
|
||||
# Verify only documents' segments from target dataset are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id == target_document.id)
|
||||
@@ -1030,8 +1029,7 @@ class TestCleanNotionDocumentTask:
|
||||
all_document_ids = [doc.id for doc in documents]
|
||||
clean_notion_document_task(all_document_ids, dataset.id)
|
||||
|
||||
# Verify all documents and segments are deleted regardless of status
|
||||
assert db_session_with_containers.query(Document).filter(Document.dataset_id == dataset.id).count() == 0
|
||||
# Verify all segments are deleted regardless of status
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset.id).count()
|
||||
== 0
|
||||
@@ -1142,8 +1140,7 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
|
||||
# Verify segments are deleted
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).count()
|
||||
== 0
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Unit tests for Service API knowledge pipeline file-upload serialization.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeUploadFile:
|
||||
id: str
|
||||
name: str
|
||||
size: int
|
||||
extension: str
|
||||
mime_type: str
|
||||
created_by: str
|
||||
created_at: datetime | None
|
||||
|
||||
|
||||
def _load_serialize_upload_file():
|
||||
api_dir = Path(__file__).resolve().parents[5]
|
||||
serializers_path = api_dir / "controllers" / "service_api" / "dataset" / "rag_pipeline" / "serializers.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("rag_pipeline_serializers", serializers_path)
|
||||
assert spec
|
||||
assert spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
||||
return module.serialize_upload_file
|
||||
|
||||
|
||||
def test_file_upload_created_at_is_isoformat_string():
|
||||
serialize_upload_file = _load_serialize_upload_file()
|
||||
|
||||
created_at = datetime(2026, 2, 8, 12, 0, 0, tzinfo=UTC)
|
||||
upload_file = FakeUploadFile()
|
||||
upload_file.id = "file-1"
|
||||
upload_file.name = "test.pdf"
|
||||
upload_file.size = 123
|
||||
upload_file.extension = "pdf"
|
||||
upload_file.mime_type = "application/pdf"
|
||||
upload_file.created_by = "account-1"
|
||||
upload_file.created_at = created_at
|
||||
|
||||
result = serialize_upload_file(upload_file)
|
||||
assert result["created_at"] == created_at.isoformat()
|
||||
|
||||
|
||||
def test_file_upload_created_at_none_serializes_to_null():
|
||||
serialize_upload_file = _load_serialize_upload_file()
|
||||
|
||||
upload_file = FakeUploadFile()
|
||||
upload_file.id = "file-1"
|
||||
upload_file.name = "test.pdf"
|
||||
upload_file.size = 123
|
||||
upload_file.extension = "pdf"
|
||||
upload_file.mime_type = "application/pdf"
|
||||
upload_file.created_by = "account-1"
|
||||
upload_file.created_at = None
|
||||
|
||||
result = serialize_upload_file(upload_file)
|
||||
assert result["created_at"] is None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Unit tests for Service API knowledge pipeline route registration.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_rag_pipeline_routes_registered():
|
||||
api_dir = Path(__file__).resolve().parents[5]
|
||||
|
||||
service_api_init = api_dir / "controllers" / "service_api" / "__init__.py"
|
||||
rag_pipeline_workflow = (
|
||||
api_dir / "controllers" / "service_api" / "dataset" / "rag_pipeline" / "rag_pipeline_workflow.py"
|
||||
)
|
||||
|
||||
assert service_api_init.exists()
|
||||
assert rag_pipeline_workflow.exists()
|
||||
|
||||
init_tree = ast.parse(service_api_init.read_text(encoding="utf-8"))
|
||||
import_found = False
|
||||
for node in ast.walk(init_tree):
|
||||
if not isinstance(node, ast.ImportFrom):
|
||||
continue
|
||||
if node.module != "dataset.rag_pipeline" or node.level != 1:
|
||||
continue
|
||||
if any(alias.name == "rag_pipeline_workflow" for alias in node.names):
|
||||
import_found = True
|
||||
break
|
||||
assert import_found, "from .dataset.rag_pipeline import rag_pipeline_workflow not found in service_api/__init__.py"
|
||||
|
||||
workflow_tree = ast.parse(rag_pipeline_workflow.read_text(encoding="utf-8"))
|
||||
route_paths: set[str] = set()
|
||||
|
||||
for node in ast.walk(workflow_tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
for decorator in node.decorator_list:
|
||||
if not isinstance(decorator, ast.Call):
|
||||
continue
|
||||
if not isinstance(decorator.func, ast.Attribute):
|
||||
continue
|
||||
if decorator.func.attr != "route":
|
||||
continue
|
||||
if not decorator.args:
|
||||
continue
|
||||
first_arg = decorator.args[0]
|
||||
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
|
||||
route_paths.add(first_arg.value)
|
||||
|
||||
assert "/datasets/<uuid:dataset_id>/pipeline/datasource-plugins" in route_paths
|
||||
assert "/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run" in route_paths
|
||||
assert "/datasets/<uuid:dataset_id>/pipeline/run" in route_paths
|
||||
assert "/datasets/pipeline/file-upload" in route_paths
|
||||
211
api/tests/unit_tests/core/tools/test_base_tool.py
Normal file
211
api/tests/unit_tests/core/tools/test_base_tool.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
|
||||
|
||||
|
||||
class DummyCastType:
|
||||
def cast_value(self, value: Any) -> str:
|
||||
return f"cast:{value}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyParameter:
|
||||
name: str
|
||||
type: DummyCastType
|
||||
form: str = "llm"
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
options: list[Any] | None = None
|
||||
llm_description: str | None = None
|
||||
|
||||
|
||||
class DummyTool(Tool):
|
||||
def __init__(self, entity: ToolEntity, runtime: ToolRuntime):
|
||||
super().__init__(entity=entity, runtime=runtime)
|
||||
self.result: ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None] = (
|
||||
self.create_text_message("default")
|
||||
)
|
||||
self.runtime_parameter_overrides: list[Any] | None = None
|
||||
self.last_invocation: dict[str, Any] | None = None
|
||||
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.BUILT_IN
|
||||
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_parameters: dict[str, Any],
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]:
|
||||
self.last_invocation = {
|
||||
"user_id": user_id,
|
||||
"tool_parameters": tool_parameters,
|
||||
"conversation_id": conversation_id,
|
||||
"app_id": app_id,
|
||||
"message_id": message_id,
|
||||
}
|
||||
return self.result
|
||||
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
if self.runtime_parameter_overrides is not None:
|
||||
return self.runtime_parameter_overrides
|
||||
return super().get_runtime_parameters(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool(runtime: ToolRuntime | None = None) -> DummyTool:
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="dummy", label=I18nObject(en_US="dummy"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = runtime or ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER, runtime_parameters={})
|
||||
return DummyTool(entity=entity, runtime=runtime)
|
||||
|
||||
|
||||
def test_invoke_supports_single_message_and_parameter_casting():
|
||||
runtime = ToolRuntime(
|
||||
tenant_id="tenant-1",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
runtime_parameters={"from_runtime": "runtime-value"},
|
||||
)
|
||||
tool = _build_tool(runtime)
|
||||
tool.entity.parameters = cast(
|
||||
Any,
|
||||
[
|
||||
DummyParameter(name="unused", type=DummyCastType()),
|
||||
DummyParameter(name="age", type=DummyCastType()),
|
||||
],
|
||||
)
|
||||
tool.result = tool.create_text_message("ok")
|
||||
|
||||
messages = list(
|
||||
tool.invoke(
|
||||
user_id="user-1",
|
||||
tool_parameters={"age": "18", "raw": "keep"},
|
||||
conversation_id="conv-1",
|
||||
app_id="app-1",
|
||||
message_id="msg-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].message.text == "ok"
|
||||
assert tool.last_invocation == {
|
||||
"user_id": "user-1",
|
||||
"tool_parameters": {"age": "cast:18", "raw": "keep", "from_runtime": "runtime-value"},
|
||||
"conversation_id": "conv-1",
|
||||
"app_id": "app-1",
|
||||
"message_id": "msg-1",
|
||||
}
|
||||
|
||||
|
||||
def test_invoke_supports_list_and_generator_results():
|
||||
tool = _build_tool()
|
||||
tool.result = [tool.create_text_message("a"), tool.create_text_message("b")]
|
||||
list_messages = list(tool.invoke(user_id="user-1", tool_parameters={}))
|
||||
assert [msg.message.text for msg in list_messages] == ["a", "b"]
|
||||
|
||||
def _message_generator() -> Generator[ToolInvokeMessage, None, None]:
|
||||
yield tool.create_text_message("g1")
|
||||
yield tool.create_text_message("g2")
|
||||
|
||||
tool.result = _message_generator()
|
||||
generated_messages = list(tool.invoke(user_id="user-2", tool_parameters={}))
|
||||
assert [msg.message.text for msg in generated_messages] == ["g1", "g2"]
|
||||
|
||||
|
||||
def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
|
||||
tool = _build_tool()
|
||||
new_runtime = ToolRuntime(tenant_id="tenant-2", invoke_from=InvokeFrom.EXPLORE, runtime_parameters={})
|
||||
|
||||
forked = tool.fork_tool_runtime(new_runtime)
|
||||
|
||||
assert isinstance(forked, DummyTool)
|
||||
assert forked is not tool
|
||||
assert forked.runtime == new_runtime
|
||||
assert forked.entity == tool.entity
|
||||
assert forked.entity is not tool.entity
|
||||
|
||||
|
||||
def test_get_runtime_parameters_and_merge_runtime_parameters():
|
||||
tool = _build_tool()
|
||||
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
|
||||
tool.entity.parameters = cast(Any, [original])
|
||||
|
||||
default_runtime_parameters = tool.get_runtime_parameters()
|
||||
assert default_runtime_parameters == [original]
|
||||
|
||||
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
|
||||
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
|
||||
tool.runtime_parameter_overrides = [override, appended]
|
||||
|
||||
merged = tool.get_merged_runtime_parameters()
|
||||
assert len(merged) == 2
|
||||
assert merged[0].name == "temperature"
|
||||
assert merged[0].form == "llm"
|
||||
assert merged[0].required is False
|
||||
assert merged[0].default == "0.5"
|
||||
assert merged[1].name == "new_param"
|
||||
|
||||
|
||||
def test_message_factory_helpers():
|
||||
tool = _build_tool()
|
||||
|
||||
image_message = tool.create_image_message("https://example.com/image.png")
|
||||
assert image_message.type == ToolInvokeMessage.MessageType.IMAGE
|
||||
assert image_message.message.text == "https://example.com/image.png"
|
||||
|
||||
file_obj = object()
|
||||
file_message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
assert file_message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert file_message.message.file_marker == "file_marker"
|
||||
assert file_message.meta == {"file": file_obj}
|
||||
|
||||
link_message = tool.create_link_message("https://example.com")
|
||||
assert link_message.type == ToolInvokeMessage.MessageType.LINK
|
||||
assert link_message.message.text == "https://example.com"
|
||||
|
||||
text_message = tool.create_text_message("hello")
|
||||
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
|
||||
assert text_message.message.text == "hello"
|
||||
|
||||
blob_message = tool.create_blob_message(b"blob", meta={"source": "unit-test"})
|
||||
assert blob_message.type == ToolInvokeMessage.MessageType.BLOB
|
||||
assert blob_message.message.blob == b"blob"
|
||||
assert blob_message.meta == {"source": "unit-test"}
|
||||
|
||||
json_message = tool.create_json_message({"k": "v"}, suppress_output=True)
|
||||
assert json_message.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert json_message.message.json_object == {"k": "v"}
|
||||
assert json_message.message.suppress_output is True
|
||||
|
||||
variable_message = tool.create_variable_message("answer", 42, stream=False)
|
||||
assert variable_message.type == ToolInvokeMessage.MessageType.VARIABLE
|
||||
assert variable_message.message.variable_name == "answer"
|
||||
assert variable_message.message.variable_value == 42
|
||||
assert variable_message.message.stream is False
|
||||
|
||||
|
||||
def test_base_abstract_invoke_placeholder_returns_none():
|
||||
tool = _build_tool()
|
||||
assert Tool._invoke(tool, user_id="u", tool_parameters={}) is None
|
||||
@@ -255,6 +255,32 @@ def test_create_variable_message():
|
||||
assert message.message.stream is False
|
||||
|
||||
|
||||
def test_create_file_message_should_include_file_marker():
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
file_obj = object()
|
||||
message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
|
||||
assert message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert message.message.file_marker == "file_marker"
|
||||
assert message.meta == {"file": file_obj}
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from core.file import File, FileTransferMethod, FileType
|
||||
@@ -493,7 +493,7 @@ def _scalar_value() -> st.SearchStrategy[int | float | str | File | None]:
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=50)
|
||||
@settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow, HealthCheck.filter_too_much], deadline=None)
|
||||
@given(_scalar_value())
|
||||
def test_build_segment_and_extract_values_for_scalar_types(value):
|
||||
seg = variable_factory.build_segment(value)
|
||||
@@ -504,7 +504,7 @@ def test_build_segment_and_extract_values_for_scalar_types(value):
|
||||
assert seg.value == value
|
||||
|
||||
|
||||
@settings(max_examples=50)
|
||||
@settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow, HealthCheck.filter_too_much], deadline=None)
|
||||
@given(values=st.lists(_scalar_value(), max_size=20))
|
||||
def test_build_segment_and_extract_values_for_array_types(values):
|
||||
seg = variable_factory.build_segment(values)
|
||||
|
||||
@@ -698,6 +698,132 @@ class TestTenantService:
|
||||
|
||||
self._assert_database_operations_called(mock_db_dependencies["db"])
|
||||
|
||||
# ==================== Member Removal Tests ====================
|
||||
|
||||
def test_remove_pending_member_deletes_orphaned_account(self):
|
||||
"""Test that removing a pending member with no other workspaces deletes the account."""
|
||||
# Arrange
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
|
||||
mock_pending_member = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="pending-user-789", email="pending@example.com", status=AccountStatus.PENDING
|
||||
)
|
||||
|
||||
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="pending-user-789", role="normal"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
query_mock_count = MagicMock()
|
||||
query_mock_count.filter_by.return_value.count.return_value = 0
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Act
|
||||
TenantService.remove_member_from_tenant(mock_tenant, mock_pending_member, mock_operator)
|
||||
|
||||
# Assert: enterprise sync still receives the correct member ID
|
||||
mock_sync.assert_called_once_with(
|
||||
workspace_id="tenant-456",
|
||||
member_id="pending-user-789",
|
||||
source="workspace_member_removed",
|
||||
)
|
||||
|
||||
# Assert: both join record and account should be deleted
|
||||
mock_db.session.delete.assert_any_call(mock_ta)
|
||||
mock_db.session.delete.assert_any_call(mock_pending_member)
|
||||
assert mock_db.session.delete.call_count == 2
|
||||
|
||||
def test_remove_pending_member_keeps_account_with_other_workspaces(self):
|
||||
"""Test that removing a pending member who belongs to other workspaces preserves the account."""
|
||||
# Arrange
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
|
||||
mock_pending_member = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="pending-user-789", email="pending@example.com", status=AccountStatus.PENDING
|
||||
)
|
||||
|
||||
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="pending-user-789", role="normal"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
# Remaining join count = 1 (still in another workspace)
|
||||
query_mock_count = MagicMock()
|
||||
query_mock_count.filter_by.return_value.count.return_value = 1
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Act
|
||||
TenantService.remove_member_from_tenant(mock_tenant, mock_pending_member, mock_operator)
|
||||
|
||||
# Assert: only the join record should be deleted, not the account
|
||||
mock_db.session.delete.assert_called_once_with(mock_ta)
|
||||
|
||||
def test_remove_active_member_preserves_account(self):
|
||||
"""Test that removing an active member never deletes the account, even with no other workspaces."""
|
||||
# Arrange
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
|
||||
mock_active_member = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="active-user-789", email="active@example.com", status=AccountStatus.ACTIVE
|
||||
)
|
||||
|
||||
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="active-user-789", role="normal"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Act
|
||||
TenantService.remove_member_from_tenant(mock_tenant, mock_active_member, mock_operator)
|
||||
|
||||
# Assert: only the join record should be deleted
|
||||
mock_db.session.delete.assert_called_once_with(mock_ta)
|
||||
|
||||
# ==================== Tenant Switching Tests ====================
|
||||
|
||||
def test_switch_tenant_success(self):
|
||||
|
||||
@@ -109,40 +109,87 @@ def mock_document_segments(document_id):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Mock database session via session_factory.create_session()."""
|
||||
"""Mock database session via session_factory.create_session().
|
||||
|
||||
After session split refactor, the code calls create_session() multiple times.
|
||||
This fixture creates shared query mocks so all sessions use the same
|
||||
query configuration, simulating database persistence across sessions.
|
||||
|
||||
The fixture automatically converts side_effect to cycle to prevent StopIteration.
|
||||
Tests configure mocks the same way as before, but behind the scenes the values
|
||||
are cycled infinitely for all sessions.
|
||||
"""
|
||||
from itertools import cycle
|
||||
|
||||
with patch("tasks.document_indexing_sync_task.session_factory") as mock_sf:
|
||||
session = MagicMock()
|
||||
# Ensure tests can observe session.close() via context manager teardown
|
||||
session.close = MagicMock()
|
||||
session.commit = MagicMock()
|
||||
sessions = []
|
||||
|
||||
# Mock session.begin() context manager to auto-commit on exit
|
||||
begin_cm = MagicMock()
|
||||
begin_cm.__enter__.return_value = session
|
||||
# Shared query mocks - all sessions use these
|
||||
shared_query = MagicMock()
|
||||
shared_filter_by = MagicMock()
|
||||
shared_scalars_result = MagicMock()
|
||||
|
||||
def _begin_exit_side_effect(*args, **kwargs):
|
||||
# session.begin().__exit__() should commit if no exception
|
||||
if args[0] is None: # No exception
|
||||
session.commit()
|
||||
# Create custom first mock that auto-cycles side_effect
|
||||
class CyclicMock(MagicMock):
|
||||
def __setattr__(self, name, value):
|
||||
if name == "side_effect" and value is not None:
|
||||
# Convert list/tuple to infinite cycle
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = cycle(value)
|
||||
super().__setattr__(name, value)
|
||||
|
||||
begin_cm.__exit__.side_effect = _begin_exit_side_effect
|
||||
session.begin.return_value = begin_cm
|
||||
shared_query.where.return_value.first = CyclicMock()
|
||||
shared_filter_by.first = CyclicMock()
|
||||
|
||||
# Mock create_session() context manager
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = session
|
||||
def _create_session():
|
||||
"""Create a new mock session for each create_session() call."""
|
||||
session = MagicMock()
|
||||
session.close = MagicMock()
|
||||
session.commit = MagicMock()
|
||||
|
||||
def _exit_side_effect(*args, **kwargs):
|
||||
session.close()
|
||||
# Mock session.begin() context manager
|
||||
begin_cm = MagicMock()
|
||||
begin_cm.__enter__.return_value = session
|
||||
|
||||
cm.__exit__.side_effect = _exit_side_effect
|
||||
mock_sf.create_session.return_value = cm
|
||||
def _begin_exit_side_effect(exc_type, exc, tb):
|
||||
# commit on success
|
||||
if exc_type is None:
|
||||
session.commit()
|
||||
# return False to propagate exceptions
|
||||
return False
|
||||
|
||||
query = MagicMock()
|
||||
session.query.return_value = query
|
||||
query.where.return_value = query
|
||||
session.scalars.return_value = MagicMock()
|
||||
yield session
|
||||
begin_cm.__exit__.side_effect = _begin_exit_side_effect
|
||||
session.begin.return_value = begin_cm
|
||||
|
||||
# Mock create_session() context manager
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = session
|
||||
|
||||
def _exit_side_effect(exc_type, exc, tb):
|
||||
session.close()
|
||||
return False
|
||||
|
||||
cm.__exit__.side_effect = _exit_side_effect
|
||||
|
||||
# All sessions use the same shared query mocks
|
||||
session.query.return_value = shared_query
|
||||
shared_query.where.return_value = shared_query
|
||||
shared_query.filter_by.return_value = shared_filter_by
|
||||
session.scalars.return_value = shared_scalars_result
|
||||
|
||||
sessions.append(session)
|
||||
# Attach helpers on the first created session for assertions across all sessions
|
||||
if len(sessions) == 1:
|
||||
session.get_all_sessions = lambda: sessions
|
||||
session.any_close_called = lambda: any(s.close.called for s in sessions)
|
||||
session.any_commit_called = lambda: any(s.commit.called for s in sessions)
|
||||
return cm
|
||||
|
||||
mock_sf.create_session.side_effect = _create_session
|
||||
|
||||
# Create first session and return it
|
||||
_create_session()
|
||||
yield sessions[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -201,8 +248,8 @@ class TestDocumentIndexingSyncTask:
|
||||
# Act
|
||||
document_indexing_sync_task(dataset_id, document_id)
|
||||
|
||||
# Assert
|
||||
mock_db_session.close.assert_called_once()
|
||||
# Assert - at least one session should have been closed
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_missing_notion_workspace_id(self, mock_db_session, mock_document, dataset_id, document_id):
|
||||
"""Test that task raises error when notion_workspace_id is missing."""
|
||||
@@ -245,6 +292,7 @@ class TestDocumentIndexingSyncTask:
|
||||
"""Test that task handles missing credentials by updating document status."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.return_value = mock_document
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
mock_datasource_provider_service.get_datasource_credentials.return_value = None
|
||||
|
||||
# Act
|
||||
@@ -254,8 +302,8 @@ class TestDocumentIndexingSyncTask:
|
||||
assert mock_document.indexing_status == "error"
|
||||
assert "Datasource credential not found" in mock_document.error
|
||||
assert mock_document.stopped_at is not None
|
||||
mock_db_session.commit.assert_called()
|
||||
mock_db_session.close.assert_called()
|
||||
assert mock_db_session.any_commit_called()
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_page_not_updated(
|
||||
self,
|
||||
@@ -269,6 +317,7 @@ class TestDocumentIndexingSyncTask:
|
||||
"""Test that task does nothing when page has not been updated."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.return_value = mock_document
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
# Return same time as stored in document
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-01T00:00:00Z"
|
||||
|
||||
@@ -278,8 +327,8 @@ class TestDocumentIndexingSyncTask:
|
||||
# Assert
|
||||
# Document status should remain unchanged
|
||||
assert mock_document.indexing_status == "completed"
|
||||
# Session should still be closed via context manager teardown
|
||||
assert mock_db_session.close.called
|
||||
# At least one session should have been closed via context manager teardown
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_successful_sync_when_page_updated(
|
||||
self,
|
||||
@@ -296,7 +345,20 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test successful sync flow when Notion page has been updated."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
# Set exact sequence of returns across calls to `.first()`:
|
||||
# 1) document (initial fetch)
|
||||
# 2) dataset (pre-check)
|
||||
# 3) dataset (cleaning phase)
|
||||
# 4) document (pre-indexing update)
|
||||
# 5) document (indexing runner fetch)
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [
|
||||
mock_document,
|
||||
mock_dataset,
|
||||
mock_dataset,
|
||||
mock_document,
|
||||
mock_document,
|
||||
]
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
|
||||
# NotionExtractor returns updated time
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
@@ -314,28 +376,40 @@ class TestDocumentIndexingSyncTask:
|
||||
mock_processor.clean.assert_called_once()
|
||||
|
||||
# Verify segments were deleted from database in batch (DELETE FROM document_segments)
|
||||
execute_sqls = [" ".join(str(c[0][0]).split()) for c in mock_db_session.execute.call_args_list]
|
||||
# Aggregate execute calls across all created sessions
|
||||
execute_sqls = []
|
||||
for s in mock_db_session.get_all_sessions():
|
||||
execute_sqls.extend([" ".join(str(c[0][0]).split()) for c in s.execute.call_args_list])
|
||||
assert any("DELETE FROM document_segments" in sql for sql in execute_sqls)
|
||||
|
||||
# Verify indexing runner was called
|
||||
mock_indexing_runner.run.assert_called_once_with([mock_document])
|
||||
|
||||
# Verify session operations
|
||||
assert mock_db_session.commit.called
|
||||
mock_db_session.close.assert_called_once()
|
||||
# Verify session operations (across any created session)
|
||||
assert mock_db_session.any_commit_called()
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_dataset_not_found_during_cleaning(
|
||||
self,
|
||||
mock_db_session,
|
||||
mock_datasource_provider_service,
|
||||
mock_notion_extractor,
|
||||
mock_indexing_runner,
|
||||
mock_document,
|
||||
dataset_id,
|
||||
document_id,
|
||||
):
|
||||
"""Test that task handles dataset not found during cleaning phase."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, None]
|
||||
# Sequence: document (initial), dataset (pre-check), None (cleaning), document (update), document (indexing)
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [
|
||||
mock_document,
|
||||
mock_dataset,
|
||||
None,
|
||||
mock_document,
|
||||
mock_document,
|
||||
]
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
|
||||
# Act
|
||||
@@ -344,8 +418,8 @@ class TestDocumentIndexingSyncTask:
|
||||
# Assert
|
||||
# Document should still be set to parsing
|
||||
assert mock_document.indexing_status == "parsing"
|
||||
# Session should be closed after error
|
||||
mock_db_session.close.assert_called_once()
|
||||
# At least one session should be closed after error
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_cleaning_error_continues_to_indexing(
|
||||
self,
|
||||
@@ -361,8 +435,14 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that indexing continues even if cleaning fails."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
mock_db_session.scalars.return_value.all.side_effect = Exception("Cleaning error")
|
||||
from itertools import cycle
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = cycle([mock_document, mock_dataset])
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
# Make the cleaning step fail but not the segment fetch
|
||||
processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
processor.clean.side_effect = Exception("Cleaning error")
|
||||
mock_db_session.scalars.return_value.all.return_value = []
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
|
||||
# Act
|
||||
@@ -371,7 +451,7 @@ class TestDocumentIndexingSyncTask:
|
||||
# Assert
|
||||
# Indexing should still be attempted despite cleaning error
|
||||
mock_indexing_runner.run.assert_called_once_with([mock_document])
|
||||
mock_db_session.close.assert_called_once()
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_indexing_runner_document_paused_error(
|
||||
self,
|
||||
@@ -388,7 +468,10 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that DocumentIsPausedError is handled gracefully."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
from itertools import cycle
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = cycle([mock_document, mock_dataset])
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
mock_indexing_runner.run.side_effect = DocumentIsPausedError("Document paused")
|
||||
@@ -398,7 +481,7 @@ class TestDocumentIndexingSyncTask:
|
||||
|
||||
# Assert
|
||||
# Session should be closed after handling error
|
||||
mock_db_session.close.assert_called_once()
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_indexing_runner_general_error(
|
||||
self,
|
||||
@@ -415,7 +498,10 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that general exceptions during indexing are handled."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
from itertools import cycle
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = cycle([mock_document, mock_dataset])
|
||||
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
|
||||
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
mock_indexing_runner.run.side_effect = Exception("Indexing error")
|
||||
@@ -425,7 +511,7 @@ class TestDocumentIndexingSyncTask:
|
||||
|
||||
# Assert
|
||||
# Session should be closed after error
|
||||
mock_db_session.close.assert_called_once()
|
||||
assert mock_db_session.any_close_called()
|
||||
|
||||
def test_notion_extractor_initialized_with_correct_params(
|
||||
self,
|
||||
@@ -532,7 +618,14 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that index processor clean is called with correct parameters."""
|
||||
# Arrange
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
# Sequence: document (initial), dataset (pre-check), dataset (cleaning), document (update), document (indexing)
|
||||
mock_db_session.query.return_value.where.return_value.first.side_effect = [
|
||||
mock_document,
|
||||
mock_dataset,
|
||||
mock_dataset,
|
||||
mock_document,
|
||||
mock_document,
|
||||
]
|
||||
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
|
||||
|
||||
271
web/__tests__/plugins/plugin-auth-flow.test.tsx
Normal file
271
web/__tests__/plugins/plugin-auth-flow.test.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Integration Test: Plugin Authentication Flow
|
||||
*
|
||||
* Tests the integration between PluginAuth, usePluginAuth hook,
|
||||
* Authorize/Authorized components, and credential management.
|
||||
* Verifies the complete auth flow from checking authorization status
|
||||
* to rendering the correct UI state.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'plugin.auth.setUpTip': 'Set up your credentials',
|
||||
'plugin.auth.authorized': 'Authorized',
|
||||
'plugin.auth.apiKey': 'API Key',
|
||||
'plugin.auth.oauth': 'OAuth',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockUsePluginAuth = vi.fn()
|
||||
vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({
|
||||
usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({
|
||||
default: ({ pluginPayload, canOAuth, canApiKey }: {
|
||||
pluginPayload: { provider: string }
|
||||
canOAuth: boolean
|
||||
canApiKey: boolean
|
||||
}) => (
|
||||
<div data-testid="authorize-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
{canOAuth && <span data-testid="auth-oauth">OAuth available</span>}
|
||||
{canApiKey && <span data-testid="auth-apikey">API Key available</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({
|
||||
default: ({ pluginPayload, credentials }: {
|
||||
pluginPayload: { provider: string }
|
||||
credentials: Array<{ id: string, name: string }>
|
||||
}) => (
|
||||
<div data-testid="authorized-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
<span data-testid="auth-credential-count">
|
||||
{credentials.length}
|
||||
{' '}
|
||||
credentials
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth')
|
||||
|
||||
describe('Plugin Authentication Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('Unauthorized State', () => {
|
||||
it('renders Authorize component when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('authorize-component')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows OAuth option when plugin supports it', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className to wrapper when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorized State', () => {
|
||||
it('renders Authorized component when authorized and no children', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [
|
||||
{ id: 'cred-1', name: 'My API Key', is_default: true },
|
||||
],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('authorized-component')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials')
|
||||
})
|
||||
|
||||
it('renders children instead of Authorized when authorized and children provided', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginAuth pluginPayload={basePayload}>
|
||||
<div data-testid="custom-children">Custom authorized view</div>
|
||||
</PluginAuth>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not apply className when authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).not.toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Category Integration', () => {
|
||||
it('passes correct provider to usePluginAuth for tool category', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const toolPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'google-search-provider',
|
||||
}
|
||||
|
||||
render(<PluginAuth pluginPayload={toolPayload} />)
|
||||
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true)
|
||||
expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider')
|
||||
})
|
||||
|
||||
it('passes correct provider to usePluginAuth for datasource category', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: true,
|
||||
canApiKey: false,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const dsPayload = {
|
||||
category: AuthCategory.datasource,
|
||||
provider: 'notion-datasource',
|
||||
}
|
||||
|
||||
render(<PluginAuth pluginPayload={dsPayload} />)
|
||||
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true)
|
||||
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Credentials', () => {
|
||||
it('shows credential count when multiple credentials exist', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [
|
||||
{ id: 'cred-1', name: 'API Key 1', is_default: true },
|
||||
{ id: 'cred-2', name: 'API Key 2', is_default: false },
|
||||
{ id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 },
|
||||
],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials')
|
||||
})
|
||||
})
|
||||
})
|
||||
224
web/__tests__/plugins/plugin-card-rendering.test.tsx
Normal file
224
web/__tests__/plugins/plugin-card-rendering.test.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Integration Test: Plugin Card Rendering Pipeline
|
||||
*
|
||||
* Tests the integration between Card, Icon, Title, Description,
|
||||
* OrgInfo, CornerMark, and CardMoreInfo components. Verifies that
|
||||
* plugin data flows correctly through the card rendering pipeline.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/types/app', () => ({
|
||||
Theme: { dark: 'dark', light: 'light' },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useCategories: () => ({
|
||||
categoriesMap: {
|
||||
tool: { label: 'Tool' },
|
||||
model: { label: 'Model' },
|
||||
extension: { label: 'Extension' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
|
||||
default: () => <span data-testid="partner-badge">Partner</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
|
||||
default: () => <span data-testid="verified-badge">Verified</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => (
|
||||
<div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}>
|
||||
{typeof src === 'string' ? src : 'emoji-icon'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
|
||||
default: ({ text }: { text: string }) => (
|
||||
<div data-testid="corner-mark">{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => (
|
||||
<div data-testid="description" data-rows={descriptionLineRows}>{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
/
|
||||
{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
|
||||
default: ({ text }: { text: string }) => (
|
||||
<div data-testid="placeholder">{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => (
|
||||
<div data-testid="title">{title}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: Card } = await import('@/app/components/plugins/card/index')
|
||||
type CardPayload = Parameters<typeof Card>[0]['payload']
|
||||
|
||||
describe('Plugin Card Rendering Integration', () => {
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const makePayload = (overrides = {}) => ({
|
||||
category: 'tool',
|
||||
type: 'plugin',
|
||||
name: 'google-search',
|
||||
org: 'langgenius',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
verified: true,
|
||||
badges: [] as string[],
|
||||
...overrides,
|
||||
}) as CardPayload
|
||||
|
||||
it('renders a complete plugin card with all subcomponents', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
|
||||
})
|
||||
|
||||
it('shows corner mark with category label when not hidden', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides corner mark when hideCornerMark is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} hideCornerMark />)
|
||||
|
||||
expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-installed', 'true')
|
||||
})
|
||||
|
||||
it('shows install failed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installFailed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-install-failed', 'true')
|
||||
})
|
||||
|
||||
it('renders verified badge when plugin is verified', () => {
|
||||
const payload = makePayload({ verified: true })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders partner badge when plugin has partner badge', () => {
|
||||
const payload = makePayload({ badges: ['partner'] })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders footer content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(
|
||||
<Card
|
||||
payload={payload}
|
||||
footer={<div data-testid="custom-footer">Custom footer</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders titleLeft content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(
|
||||
<Card
|
||||
payload={payload}
|
||||
titleLeft={<span data-testid="title-left-content">New</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('title-left-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses dark icon when theme is dark and icon_dark is provided', () => {
|
||||
vi.doMock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'dark' }),
|
||||
}))
|
||||
|
||||
const payload = makePayload({
|
||||
icon: 'https://example.com/icon-light.png',
|
||||
icon_dark: 'https://example.com/icon-dark.png',
|
||||
})
|
||||
|
||||
render(<Card payload={payload} />)
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading placeholder when isLoading is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} isLoading loadingFileName="uploading.difypkg" />)
|
||||
|
||||
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description with custom line rows', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} descriptionLineRows={3} />)
|
||||
|
||||
const description = screen.getByTestId('description')
|
||||
expect(description).toHaveAttribute('data-rows', '3')
|
||||
})
|
||||
})
|
||||
159
web/__tests__/plugins/plugin-data-utilities.test.ts
Normal file
159
web/__tests__/plugins/plugin-data-utilities.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Integration Test: Plugin Data Utilities
|
||||
*
|
||||
* Tests the integration between plugin utility functions, including
|
||||
* tag/category validation, form schema transformation, and
|
||||
* credential data processing. Verifies that these utilities work
|
||||
* correctly together in processing plugin metadata.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils'
|
||||
import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils'
|
||||
|
||||
type TagInput = Parameters<typeof getValidTagKeys>[0]
|
||||
|
||||
describe('Plugin Data Utilities Integration', () => {
|
||||
describe('Tag and Category Validation Pipeline', () => {
|
||||
it('validates tags and categories in a metadata processing flow', () => {
|
||||
const pluginMetadata = {
|
||||
tags: ['search', 'productivity', 'invalid-tag', 'media-generate'],
|
||||
category: 'tool',
|
||||
}
|
||||
|
||||
const validTags = getValidTagKeys(pluginMetadata.tags as TagInput)
|
||||
expect(validTags.length).toBeGreaterThan(0)
|
||||
expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length)
|
||||
|
||||
const validCategory = getValidCategoryKeys(pluginMetadata.category)
|
||||
expect(validCategory).toBeDefined()
|
||||
})
|
||||
|
||||
it('handles completely invalid metadata gracefully', () => {
|
||||
const invalidMetadata = {
|
||||
tags: ['nonexistent-1', 'nonexistent-2'],
|
||||
category: 'nonexistent-category',
|
||||
}
|
||||
|
||||
const validTags = getValidTagKeys(invalidMetadata.tags as TagInput)
|
||||
expect(validTags).toHaveLength(0)
|
||||
|
||||
const validCategory = getValidCategoryKeys(invalidMetadata.category)
|
||||
expect(validCategory).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles undefined and empty inputs', () => {
|
||||
expect(getValidTagKeys([] as TagInput)).toHaveLength(0)
|
||||
expect(getValidCategoryKeys(undefined)).toBeUndefined()
|
||||
expect(getValidCategoryKeys('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Credential Secret Masking Pipeline', () => {
|
||||
it('masks secrets when displaying credential form data', () => {
|
||||
const credentialValues = {
|
||||
api_key: 'sk-abc123456789',
|
||||
api_endpoint: 'https://api.example.com',
|
||||
secret_token: 'secret-token-value',
|
||||
description: 'My credential set',
|
||||
}
|
||||
|
||||
const secretFields = ['api_key', 'secret_token']
|
||||
|
||||
const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues)
|
||||
|
||||
expect(displayValues.api_key).toBe('[__HIDDEN__]')
|
||||
expect(displayValues.secret_token).toBe('[__HIDDEN__]')
|
||||
expect(displayValues.api_endpoint).toBe('https://api.example.com')
|
||||
expect(displayValues.description).toBe('My credential set')
|
||||
})
|
||||
|
||||
it('preserves original values when no secret fields', () => {
|
||||
const values = {
|
||||
name: 'test',
|
||||
endpoint: 'https://api.example.com',
|
||||
}
|
||||
|
||||
const result = transformFormSchemasSecretInput([], values)
|
||||
expect(result).toEqual(values)
|
||||
})
|
||||
|
||||
it('handles falsy secret values without masking', () => {
|
||||
const values = {
|
||||
api_key: '',
|
||||
secret: null as unknown as string,
|
||||
other: 'visible',
|
||||
}
|
||||
|
||||
const result = transformFormSchemasSecretInput(['api_key', 'secret'], values)
|
||||
expect(result.api_key).toBe('')
|
||||
expect(result.secret).toBeNull()
|
||||
expect(result.other).toBe('visible')
|
||||
})
|
||||
|
||||
it('does not mutate the original values object', () => {
|
||||
const original = {
|
||||
api_key: 'my-secret-key',
|
||||
name: 'test',
|
||||
}
|
||||
const originalCopy = { ...original }
|
||||
|
||||
transformFormSchemasSecretInput(['api_key'], original)
|
||||
|
||||
expect(original).toEqual(originalCopy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combined Plugin Metadata Validation', () => {
|
||||
it('processes a complete plugin entry with tags and credentials', () => {
|
||||
const pluginEntry = {
|
||||
name: 'test-plugin',
|
||||
category: 'tool',
|
||||
tags: ['search', 'invalid-tag'],
|
||||
credentials: {
|
||||
api_key: 'sk-test-key-123',
|
||||
base_url: 'https://api.test.com',
|
||||
},
|
||||
secretFields: ['api_key'],
|
||||
}
|
||||
|
||||
const validCategory = getValidCategoryKeys(pluginEntry.category)
|
||||
expect(validCategory).toBe('tool')
|
||||
|
||||
const validTags = getValidTagKeys(pluginEntry.tags as TagInput)
|
||||
expect(validTags).toContain('search')
|
||||
|
||||
const displayCredentials = transformFormSchemasSecretInput(
|
||||
pluginEntry.secretFields,
|
||||
pluginEntry.credentials,
|
||||
)
|
||||
expect(displayCredentials.api_key).toBe('[__HIDDEN__]')
|
||||
expect(displayCredentials.base_url).toBe('https://api.test.com')
|
||||
|
||||
expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123')
|
||||
})
|
||||
|
||||
it('handles multiple plugins in batch processing', () => {
|
||||
const plugins = [
|
||||
{ tags: ['search', 'productivity'], category: 'tool' },
|
||||
{ tags: ['image', 'design'], category: 'model' },
|
||||
{ tags: ['invalid'], category: 'extension' },
|
||||
]
|
||||
|
||||
const results = plugins.map(p => ({
|
||||
validTags: getValidTagKeys(p.tags as TagInput),
|
||||
validCategory: getValidCategoryKeys(p.category),
|
||||
}))
|
||||
|
||||
expect(results[0].validTags.length).toBeGreaterThan(0)
|
||||
expect(results[0].validCategory).toBe('tool')
|
||||
|
||||
expect(results[1].validTags).toContain('image')
|
||||
expect(results[1].validTags).toContain('design')
|
||||
expect(results[1].validCategory).toBe('model')
|
||||
|
||||
expect(results[2].validTags).toHaveLength(0)
|
||||
expect(results[2].validCategory).toBe('extension')
|
||||
})
|
||||
})
|
||||
})
|
||||
269
web/__tests__/plugins/plugin-install-flow.test.ts
Normal file
269
web/__tests__/plugins/plugin-install-flow.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Integration Test: Plugin Installation Flow
|
||||
*
|
||||
* Tests the integration between GitHub release fetching, version comparison,
|
||||
* upload handling, and task status polling. Verifies the complete plugin
|
||||
* installation pipeline from source discovery to completion.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
GITHUB_ACCESS_TOKEN: '',
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
|
||||
}))
|
||||
|
||||
const mockUploadGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
checkTaskStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMajor, aMinor = 0, aPatch = 0] = parse(a)
|
||||
const [bMajor, bMinor = 0, bPatch = 0] = parse(b)
|
||||
if (aMajor !== bMajor)
|
||||
return aMajor > bMajor ? 1 : -1
|
||||
if (aMinor !== bMinor)
|
||||
return aMinor > bMinor ? 1 : -1
|
||||
if (aPatch !== bPatch)
|
||||
return aPatch > bPatch ? 1 : -1
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMaj, aMin = 0, aPat = 0] = parse(a)
|
||||
const [bMaj, bMin = 0, bPat = 0] = parse(b)
|
||||
if (aMaj !== bMaj)
|
||||
return bMaj - aMaj
|
||||
if (aMin !== bMin)
|
||||
return bMin - aMin
|
||||
return bPat - aPat
|
||||
})[0]
|
||||
},
|
||||
}))
|
||||
|
||||
const { useGitHubReleases, useGitHubUpload } = await import(
|
||||
'@/app/components/plugins/install-plugin/hooks',
|
||||
)
|
||||
|
||||
describe('Plugin Installation Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.fetch = vi.fn()
|
||||
})
|
||||
|
||||
describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => {
|
||||
it('fetches releases, checks for updates, and uploads the new version', async () => {
|
||||
const mockReleases = [
|
||||
{
|
||||
tag_name: 'v2.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }],
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.5.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }],
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
|
||||
},
|
||||
]
|
||||
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReleases),
|
||||
})
|
||||
|
||||
mockUploadGitHub.mockResolvedValue({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
expect(releases).toHaveLength(3)
|
||||
expect(releases[0].tag_name).toBe('v2.0.0')
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(true)
|
||||
expect(toastProps.message).toContain('v2.0.0')
|
||||
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
const onSuccess = vi.fn()
|
||||
const result = await handleUpload(
|
||||
'https://github.com/test-org/test-repo',
|
||||
'v2.0.0',
|
||||
'plugin-v2.difypkg',
|
||||
onSuccess,
|
||||
)
|
||||
|
||||
expect(mockUploadGitHub).toHaveBeenCalledWith(
|
||||
'https://github.com/test-org/test-repo',
|
||||
'v2.0.0',
|
||||
'plugin-v2.difypkg',
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles no new version available', async () => {
|
||||
const mockReleases = [
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
|
||||
},
|
||||
]
|
||||
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReleases),
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('info')
|
||||
expect(toastProps.message).toBe('No new version available')
|
||||
})
|
||||
|
||||
it('handles empty releases', async () => {
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
expect(releases).toHaveLength(0)
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('error')
|
||||
expect(toastProps.message).toBe('Input releases is empty')
|
||||
})
|
||||
|
||||
it('handles fetch failure gracefully', async () => {
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const { fetchReleases } = useGitHubReleases()
|
||||
const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo')
|
||||
|
||||
expect(releases).toEqual([])
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles upload failure gracefully', async () => {
|
||||
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
await expect(
|
||||
handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess),
|
||||
).rejects.toThrow('Upload failed')
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task Status Polling Integration', () => {
|
||||
it('polls until plugin installation succeeds', async () => {
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
|
||||
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
|
||||
|
||||
await vi.doMock('@/utils', () => ({
|
||||
sleep: () => Promise.resolve(),
|
||||
}))
|
||||
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('success')
|
||||
})
|
||||
|
||||
it('returns failure when plugin not found in task', async () => {
|
||||
const mockCheckTaskStatus = vi.fn().mockResolvedValue({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
|
||||
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
|
||||
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toBe('Plugin package not found')
|
||||
})
|
||||
|
||||
it('stops polling when stop() is called', async () => {
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
checker.stop()
|
||||
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('success')
|
||||
})
|
||||
})
|
||||
})
|
||||
97
web/__tests__/plugins/plugin-marketplace-to-install.test.tsx
Normal file
97
web/__tests__/plugins/plugin-marketplace-to-install.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Plugin Marketplace to Install Flow', () => {
|
||||
describe('install permission validation pipeline', () => {
|
||||
const systemFeaturesAll = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const systemFeaturesMarketplaceOnly = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const systemFeaturesOfficialOnly = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
|
||||
it('should allow marketplace plugin when all sources allowed', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow github plugin when all sources allowed', () => {
|
||||
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should block github plugin when marketplace only', () => {
|
||||
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
|
||||
expect(result.canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugin when marketplace only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow official plugin when official only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should block community plugin when official only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
|
||||
expect(result.canInstall).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin source classification', () => {
|
||||
it('should correctly classify plugin install sources', () => {
|
||||
const sources = ['marketplace', 'github', 'package'] as const
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const results = sources.map(source => ({
|
||||
source,
|
||||
canInstall: pluginInstallLimit(
|
||||
{ from: source, verification: { authorized_category: 'langgenius' } } as never,
|
||||
features as never,
|
||||
).canInstall,
|
||||
}))
|
||||
|
||||
expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true)
|
||||
expect(results.find(r => r.source === 'github')?.canInstall).toBe(false)
|
||||
expect(results.find(r => r.source === 'package')?.canInstall).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
120
web/__tests__/plugins/plugin-page-filter-management.test.tsx
Normal file
120
web/__tests__/plugins/plugin-page-filter-management.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store'
|
||||
|
||||
describe('Plugin Page Filter Management Integration', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
result.current.setCategoryList([])
|
||||
result.current.setShowTagManagementModal(false)
|
||||
result.current.setShowCategoryManagementModal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tag and category filter lifecycle', () => {
|
||||
it('should manage full tag lifecycle: add -> update -> clear', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
const initialTags = [
|
||||
{ name: 'search', label: { en_US: 'Search' } },
|
||||
{ name: 'productivity', label: { en_US: 'Productivity' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(initialTags as never[])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(2)
|
||||
|
||||
const updatedTags = [
|
||||
...initialTags,
|
||||
{ name: 'image', label: { en_US: 'Image' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(updatedTags as never[])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(3)
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should manage full category lifecycle: add -> update -> clear', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
const categories = [
|
||||
{ name: 'tool', label: { en_US: 'Tool' } },
|
||||
{ name: 'model', label: { en_US: 'Model' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList(categories as never[])
|
||||
})
|
||||
expect(result.current.categoryList).toHaveLength(2)
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList([])
|
||||
})
|
||||
expect(result.current.categoryList).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal state management', () => {
|
||||
it('should manage tag management modal independently', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
expect(result.current.showCategoryManagementModal).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(false)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage category management modal independently', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should support both modals open simultaneously', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state persistence across renders', () => {
|
||||
it('should maintain filter state when re-rendered', () => {
|
||||
const { result, rerender } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList([{ name: 'search' }] as never[])
|
||||
result.current.setCategoryList([{ name: 'tool' }] as never[])
|
||||
})
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current.tagList).toHaveLength(1)
|
||||
expect(result.current.categoryList).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
369
web/__tests__/tools/tool-browsing-and-filtering.test.tsx
Normal file
369
web/__tests__/tools/tool-browsing-and-filtering.test.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Browsing & Filtering Flow
|
||||
*
|
||||
* Tests the integration between ProviderList, TabSliderNew, LabelFilter,
|
||||
* Input (search), and card rendering. Verifies that tab switching, keyword
|
||||
* filtering, and label filtering work together correctly.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
// ---- Mocks ----
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'type.builtIn': 'Built-in',
|
||||
'type.custom': 'Custom',
|
||||
'type.workflow': 'Workflow',
|
||||
'noTools': 'No tools found',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (key: string) => key,
|
||||
tags: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: () => ({ data: null }),
|
||||
useInvalidateInstalledPluginList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCollections: Collection[] = [
|
||||
{
|
||||
id: 'google-search',
|
||||
name: 'google_search',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Google Search Tool', zh_Hans: 'Google搜索工具' },
|
||||
icon: 'https://example.com/google.png',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: true,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
},
|
||||
{
|
||||
id: 'weather-api',
|
||||
name: 'weather_api',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Weather API Tool', zh_Hans: '天气API工具' },
|
||||
icon: 'https://example.com/weather.png',
|
||||
label: { en_US: 'Weather API', zh_Hans: '天气API' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['utility'],
|
||||
},
|
||||
{
|
||||
id: 'my-custom-tool',
|
||||
name: 'my_custom_tool',
|
||||
author: 'User',
|
||||
description: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
icon: 'https://example.com/custom.png',
|
||||
label: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
type: CollectionType.custom,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
{
|
||||
id: 'workflow-tool-1',
|
||||
name: 'workflow_tool_1',
|
||||
author: 'User',
|
||||
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
icon: 'https://example.com/workflow.png',
|
||||
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
type: CollectionType.workflow,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: mockCollections,
|
||||
refetch: mockRefetch,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tab-slider-new', () => ({
|
||||
default: ({ value, onChange, options }: { value: string, onChange: (v: string) => void, options: Array<{ value: string, text: string }> }) => (
|
||||
<div data-testid="tab-slider">
|
||||
{options.map((opt: { value: string, text: string }) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`tab-${opt.value}`}
|
||||
data-active={value === opt.value ? 'true' : 'false'}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({ value, onChange, onClear, showLeftIcon, showClearIcon, wrapperClassName }: {
|
||||
value: string
|
||||
onChange: (e: { target: { value: string } }) => void
|
||||
onClear: () => void
|
||||
showLeftIcon?: boolean
|
||||
showClearIcon?: boolean
|
||||
wrapperClassName?: string
|
||||
}) => (
|
||||
<div data-testid="search-input-wrapper" className={wrapperClassName}>
|
||||
<input
|
||||
data-testid="search-input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-left-icon={showLeftIcon ? 'true' : 'false'}
|
||||
data-clear-icon={showClearIcon ? 'true' : 'false'}
|
||||
/>
|
||||
{showClearIcon && value && (
|
||||
<button data-testid="clear-search" onClick={onClear}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, className }: { payload: { brief: Record<string, string> | string, name: string }, className?: string }) => {
|
||||
const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief
|
||||
return (
|
||||
<div data-testid={`card-${payload.name}`} className={className}>
|
||||
<span>{payload.name}</span>
|
||||
<span>{briefText}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-more-info">{tags.join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/filter', () => ({
|
||||
default: ({ value: _value, onChange }: { value: string[], onChange: (v: string[]) => void }) => (
|
||||
<div data-testid="label-filter">
|
||||
<button data-testid="filter-search" onClick={() => onChange(['search'])}>Filter: search</button>
|
||||
<button data-testid="filter-utility" onClick={() => onChange(['utility'])}>Filter: utility</button>
|
||||
<button data-testid="filter-clear" onClick={() => onChange([])}>Clear filter</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
|
||||
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/detail', () => ({
|
||||
default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => (
|
||||
<div data-testid="provider-detail">
|
||||
<span data-testid="detail-name">{collection.name}</span>
|
||||
<button data-testid="detail-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/empty', () => ({
|
||||
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
|
||||
default: ({ detail, onHide }: { detail: unknown, onHide: () => void }) => (
|
||||
detail ? <div data-testid="plugin-detail-panel"><button onClick={onHide}>Close</button></div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="empty-state">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: () => <div data-testid="mcp-list">MCP List</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/types', () => ({
|
||||
ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' },
|
||||
}))
|
||||
|
||||
const { default: ProviderList } = await import('@/app/components/tools/provider-list')
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Tool Browsing & Filtering Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders tab options and built-in tools by default', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-builtin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-api')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-workflow')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-mcp')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-my_custom_tool')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-workflow_tool_1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters tools by keyword search', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears search keyword and shows all tools again', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('filters tools by label tags', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears label filter and shows all tools', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-utility'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-clear'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('combines keyword search and label filter', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Weather' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens provider detail when clicking a non-plugin collection card', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-name')).toHaveTextContent('google_search')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes provider detail and deselects current provider', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('detail-close'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows label filter for non-MCP tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows search input on all tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
239
web/__tests__/tools/tool-data-processing.test.ts
Normal file
239
web/__tests__/tools/tool-data-processing.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Integration Test: Tool Data Processing Pipeline
|
||||
*
|
||||
* Tests the integration between tool utility functions and type conversions.
|
||||
* Verifies that data flows correctly through the processing pipeline:
|
||||
* raw API data → form schemas → form values → configured values.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index'
|
||||
import {
|
||||
addDefaultValue,
|
||||
generateFormValue,
|
||||
getConfiguredValue,
|
||||
getPlainValue,
|
||||
getStructureValue,
|
||||
toolCredentialToFormSchemas,
|
||||
toolParametersToFormSchemas,
|
||||
toType,
|
||||
triggerEventParametersToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
|
||||
describe('Tool Data Processing Pipeline Integration', () => {
|
||||
describe('End-to-end: API schema → form schema → form value', () => {
|
||||
it('processes tool parameters through the full pipeline', () => {
|
||||
const rawParameters = [
|
||||
{
|
||||
name: 'query',
|
||||
label: { en_US: 'Search Query', zh_Hans: '搜索查询' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'hello',
|
||||
form: 'llm',
|
||||
human_description: { en_US: 'Enter your search query', zh_Hans: '输入搜索查询' },
|
||||
llm_description: 'The search query string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: { en_US: 'Result Limit', zh_Hans: '结果限制' },
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: '10',
|
||||
form: 'form',
|
||||
human_description: { en_US: 'Maximum results', zh_Hans: '最大结果数' },
|
||||
llm_description: 'Limit for results',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const formSchemas = toolParametersToFormSchemas(rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0])
|
||||
expect(formSchemas).toHaveLength(2)
|
||||
expect(formSchemas[0].variable).toBe('query')
|
||||
expect(formSchemas[0].required).toBe(true)
|
||||
expect(formSchemas[0].type).toBe('text-input')
|
||||
expect(formSchemas[1].variable).toBe('limit')
|
||||
expect(formSchemas[1].type).toBe('number-input')
|
||||
|
||||
const withDefaults = addDefaultValue({}, formSchemas)
|
||||
expect(withDefaults.query).toBe('hello')
|
||||
expect(withDefaults.limit).toBe('10')
|
||||
|
||||
const formValues = generateFormValue({}, formSchemas, false)
|
||||
expect(formValues).toBeDefined()
|
||||
expect(formValues.query).toBeDefined()
|
||||
expect(formValues.limit).toBeDefined()
|
||||
})
|
||||
|
||||
it('processes tool credentials through the pipeline', () => {
|
||||
const rawCredentials = [
|
||||
{
|
||||
name: 'api_key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
type: 'secret-input',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' },
|
||||
help: { en_US: 'Your API key', zh_Hans: '你的 API 密钥' },
|
||||
url: 'https://example.com/get-key',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const credentialSchemas = toolCredentialToFormSchemas(rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0])
|
||||
expect(credentialSchemas).toHaveLength(1)
|
||||
expect(credentialSchemas[0].variable).toBe('api_key')
|
||||
expect(credentialSchemas[0].required).toBe(true)
|
||||
expect(credentialSchemas[0].type).toBe('secret-input')
|
||||
})
|
||||
|
||||
it('processes trigger event parameters through the pipeline', () => {
|
||||
const rawParams = [
|
||||
{
|
||||
name: 'event_type',
|
||||
label: { en_US: 'Event Type', zh_Hans: '事件类型' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'push',
|
||||
form: 'form',
|
||||
description: { en_US: 'Type of event', zh_Hans: '事件类型' },
|
||||
options: [
|
||||
{ value: 'push', label: { en_US: 'Push', zh_Hans: '推送' } },
|
||||
{ value: 'pull', label: { en_US: 'Pull', zh_Hans: '拉取' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const schemas = triggerEventParametersToFormSchemas(rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0])
|
||||
expect(schemas).toHaveLength(1)
|
||||
expect(schemas[0].name).toBe('event_type')
|
||||
expect(schemas[0].type).toBe('select')
|
||||
expect(schemas[0].options).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type conversion integration', () => {
|
||||
it('converts all supported types correctly', () => {
|
||||
const typeConversions = [
|
||||
{ input: 'string', expected: 'text-input' },
|
||||
{ input: 'number', expected: 'number-input' },
|
||||
{ input: 'boolean', expected: 'checkbox' },
|
||||
{ input: 'select', expected: 'select' },
|
||||
{ input: 'secret-input', expected: 'secret-input' },
|
||||
{ input: 'file', expected: 'file' },
|
||||
{ input: 'files', expected: 'files' },
|
||||
]
|
||||
|
||||
typeConversions.forEach(({ input, expected }) => {
|
||||
expect(toType(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the original type for unrecognized types', () => {
|
||||
expect(toType('unknown-type')).toBe('unknown-type')
|
||||
expect(toType('app-selector')).toBe('app-selector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value extraction integration', () => {
|
||||
it('wraps values with getStructureValue and extracts inner value with getPlainValue', () => {
|
||||
const plainInput = { query: 'test', limit: 10 }
|
||||
const structured = getStructureValue(plainInput)
|
||||
|
||||
expect(structured.query).toEqual({ value: 'test' })
|
||||
expect(structured.limit).toEqual({ value: 10 })
|
||||
|
||||
const objectStructured = {
|
||||
query: { value: { type: 'constant', content: 'test search' } },
|
||||
limit: { value: { type: 'constant', content: 10 } },
|
||||
}
|
||||
const extracted = getPlainValue(objectStructured)
|
||||
expect(extracted.query).toEqual({ type: 'constant', content: 'test search' })
|
||||
expect(extracted.limit).toEqual({ type: 'constant', content: 10 })
|
||||
})
|
||||
|
||||
it('handles getConfiguredValue for workflow tool configurations', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
{ variable: 'format', type: 'select', default: 'json' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({}, formSchemas)
|
||||
expect(configured).toBeDefined()
|
||||
expect(configured.query).toBeDefined()
|
||||
expect(configured.format).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves existing values in getConfiguredValue', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas)
|
||||
expect(configured.query).toBe('my-existing-query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent utilities integration', () => {
|
||||
it('sorts agent thoughts and enriches with file infos end-to-end', () => {
|
||||
const thoughts = [
|
||||
{ id: 't3', position: 3, tool: 'search', files: ['f1'] },
|
||||
{ id: 't1', position: 1, tool: 'analyze', files: [] },
|
||||
{ id: 't2', position: 2, tool: 'summarize', files: ['f2'] },
|
||||
] as Parameters<typeof sortAgentSorts>[0]
|
||||
|
||||
const messageFiles = [
|
||||
{ id: 'f1', name: 'result.txt', type: 'document' },
|
||||
{ id: 'f2', name: 'summary.pdf', type: 'document' },
|
||||
] as Parameters<typeof addFileInfos>[1]
|
||||
|
||||
const sorted = sortAgentSorts(thoughts)
|
||||
expect(sorted[0].id).toBe('t1')
|
||||
expect(sorted[1].id).toBe('t2')
|
||||
expect(sorted[2].id).toBe('t3')
|
||||
|
||||
const enriched = addFileInfos(sorted, messageFiles)
|
||||
expect(enriched[0].message_files).toBeUndefined()
|
||||
expect(enriched[1].message_files).toHaveLength(1)
|
||||
expect(enriched[1].message_files![0].id).toBe('f2')
|
||||
expect(enriched[2].message_files).toHaveLength(1)
|
||||
expect(enriched[2].message_files![0].id).toBe('f1')
|
||||
})
|
||||
|
||||
it('handles null inputs gracefully in the pipeline', () => {
|
||||
const sortedNull = sortAgentSorts(null as never)
|
||||
expect(sortedNull).toBeNull()
|
||||
|
||||
const enrichedNull = addFileInfos(null as never, [])
|
||||
expect(enrichedNull).toBeNull()
|
||||
|
||||
// addFileInfos with empty list and null files returns the mapped (empty) list
|
||||
const enrichedEmptyList = addFileInfos([], null as never)
|
||||
expect(enrichedEmptyList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default value application', () => {
|
||||
it('applies defaults only to empty fields, preserving user values', () => {
|
||||
const userValues = { api_key: 'user-provided-key' }
|
||||
const schemas = [
|
||||
{ variable: 'api_key', type: 'text-input', default: 'default-key', name: 'api_key' },
|
||||
{ variable: 'secret', type: 'secret-input', default: 'default-secret', name: 'secret' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue(userValues, schemas)
|
||||
expect(result.api_key).toBe('user-provided-key')
|
||||
expect(result.secret).toBe('default-secret')
|
||||
})
|
||||
|
||||
it('handles boolean type conversion in defaults', () => {
|
||||
const schemas = [
|
||||
{ variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue({ enabled: 'true' }, schemas)
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
548
web/__tests__/tools/tool-provider-detail-flow.test.tsx
Normal file
548
web/__tests__/tools/tool-provider-detail-flow.test.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Provider Detail Flow
|
||||
*
|
||||
* Tests the integration between ProviderDetail, ConfigCredential,
|
||||
* EditCustomToolModal, WorkflowToolModal, and service APIs.
|
||||
* Verifies that different provider types render correctly and
|
||||
* handle auth/edit/delete flows.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'auth.authorized': 'Authorized',
|
||||
'auth.unauthorized': 'Set up credentials',
|
||||
'auth.setup': 'NEEDS SETUP',
|
||||
'createTool.editAction': 'Edit',
|
||||
'createTool.deleteToolConfirmTitle': 'Delete Tool',
|
||||
'createTool.deleteToolConfirmContent': 'Are you sure?',
|
||||
'createTool.toolInput.title': 'Tool Input',
|
||||
'createTool.toolInput.required': 'Required',
|
||||
'openInStudio': 'Open in Studio',
|
||||
'api.actionSuccess': 'Action succeeded',
|
||||
}
|
||||
if (key === 'detailPanel.actionNum')
|
||||
return `${opts?.num ?? 0} actions`
|
||||
if (key === 'includeToolNum')
|
||||
return `${opts?.num ?? 0} actions`
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModelModal: mockSetShowModelModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [
|
||||
{ provider: 'model-provider-1', name: 'Model Provider 1' },
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([
|
||||
{ name: 'tool-1', description: { en_US: 'Tool 1' }, parameters: [] },
|
||||
{ name: 'tool-2', description: { en_US: 'Tool 2' }, parameters: [] },
|
||||
])
|
||||
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
|
||||
credentials: { auth_type: 'none' },
|
||||
schema: '',
|
||||
schema_type: 'openapi',
|
||||
})
|
||||
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
|
||||
workflow_app_id: 'app-123',
|
||||
tool: {
|
||||
parameters: [
|
||||
{ name: 'query', llm_description: 'Search query', form: 'text', required: true, type: 'string' },
|
||||
],
|
||||
labels: ['search'],
|
||||
},
|
||||
})
|
||||
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
|
||||
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
|
||||
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
|
||||
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
|
||||
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
|
||||
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
|
||||
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
|
||||
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
|
||||
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
|
||||
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
|
||||
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({}),
|
||||
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/drawer', () => ({
|
||||
default: ({ isOpen, children, onClose }: { isOpen: boolean, children: React.ReactNode, onClose: () => void }) => (
|
||||
isOpen
|
||||
? (
|
||||
<div data-testid="drawer">
|
||||
{children}
|
||||
<button data-testid="drawer-close" onClick={onClose}>Close Drawer</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ title, isShow, onConfirm, onCancel }: {
|
||||
title: string
|
||||
content: string
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<span>{title}</span>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
||||
LinkExternal02: () => <span data-testid="link-icon" />,
|
||||
Settings01: () => <span data-testid="settings-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ConfigurationMethodEnum: { predefinedModel: 'predefined-model' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <span data-testid={`indicator-${color}`} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
{' '}
|
||||
/
|
||||
{' '}
|
||||
{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void, payload: unknown }) => (
|
||||
<div data-testid="edit-custom-modal">
|
||||
<button data-testid="custom-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="custom-modal-save" onClick={() => onEdit({ name: 'updated', labels: [] })}>Save</button>
|
||||
<button data-testid="custom-modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
default: ({ onCancel, onSaved, onRemove }: { collection: Collection, onCancel: () => void, onSaved: (v: Record<string, unknown>) => void, onRemove: () => void }) => (
|
||||
<div data-testid="config-credential">
|
||||
<button data-testid="cred-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}>Save</button>
|
||||
<button data-testid="cred-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>
|
||||
<button data-testid="wf-modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/tool-item', () => ({
|
||||
default: ({ tool }: { tool: { name: string } }) => (
|
||||
<div data-testid={`tool-item-${tool.name}`}>{tool.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail')
|
||||
|
||||
const makeCollection = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'test-collection',
|
||||
name: 'test_collection',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockOnHide = vi.fn()
|
||||
const mockOnRefreshData = vi.fn()
|
||||
|
||||
describe('Tool Provider Detail Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Built-in Provider', () => {
|
||||
it('renders provider detail with title, author, and description', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('Dify')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Test collection description')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads tool list from API on mount', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tool-item-tool-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-item-tool-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Set up credentials" button when not authorized and needs auth', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Authorized" button when authorized', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authorized')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens ConfigCredential when clicking auth button (built-in type)', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { api_key: 'test-key' })
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('removes credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-remove'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Provider', () => {
|
||||
it('opens model modal when clicking auth button for model type', async () => {
|
||||
const collection = makeCollection({
|
||||
id: 'model-provider-1',
|
||||
type: CollectionType.model,
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
currentProvider: expect.objectContaining({ provider: 'model-provider-1' }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Provider', () => {
|
||||
it('fetches custom collection details and shows edit button', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit modal and saves changes', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateCustomCollection).toHaveBeenCalled()
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows delete confirmation and removes collection', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Provider', () => {
|
||||
it('fetches workflow tool detail and shows "Open in Studio" and "Edit" buttons', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open in Studio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows workflow tool parameters', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search query')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes workflow tool through confirmation dialog', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('wf-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drawer Interaction', () => {
|
||||
it('calls onHide when closing the drawer', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('drawer-close'))
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 658 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 6.66675H17.5V15.8334H2.5V6.66675Z" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16675 6.66659V3.33325H8.33341V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 6.66659V3.33325H15.8334V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 509 B |
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Playground"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Playground.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Playground'
|
||||
|
||||
export default Icon
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "20",
|
||||
"height": "20",
|
||||
"viewBox": "0 0 20 20",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2.5 6.66675H17.5V15.8334H2.5V6.66675Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4.16675 6.66659V3.33325H8.33341V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6667 6.66659V3.33325H15.8334V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Plugin"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Plugin.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Plugin'
|
||||
|
||||
export default Icon
|
||||
@@ -1,5 +1,3 @@
|
||||
export { default as BoxSparkleFill } from './BoxSparkleFill'
|
||||
export { default as LeftCorner } from './LeftCorner'
|
||||
export { default as Playground } from './Playground'
|
||||
export { default as Plugin } from './Plugin'
|
||||
export { default as Trigger } from './Trigger'
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { Highlight } from '@/app/components/base/icons/src/public/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
const PremiumBadgeVariants = cva(
|
||||
'premium-badge',
|
||||
|
||||
@@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@@ -63,8 +63,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@@ -1,59 +1,10 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from './hooks'
|
||||
|
||||
// Create mock translation function
|
||||
const mockT = vi.fn((key: string, _options?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tags.agent': 'Agent',
|
||||
'tags.rag': 'RAG',
|
||||
'tags.search': 'Search',
|
||||
'tags.image': 'Image',
|
||||
'tags.videos': 'Videos',
|
||||
'tags.weather': 'Weather',
|
||||
'tags.finance': 'Finance',
|
||||
'tags.design': 'Design',
|
||||
'tags.travel': 'Travel',
|
||||
'tags.social': 'Social',
|
||||
'tags.news': 'News',
|
||||
'tags.medical': 'Medical',
|
||||
'tags.productivity': 'Productivity',
|
||||
'tags.education': 'Education',
|
||||
'tags.business': 'Business',
|
||||
'tags.entertainment': 'Entertainment',
|
||||
'tags.utilities': 'Utilities',
|
||||
'tags.other': 'Other',
|
||||
'category.models': 'Models',
|
||||
'category.tools': 'Tools',
|
||||
'category.datasources': 'Datasources',
|
||||
'category.agents': 'Agents',
|
||||
'category.extensions': 'Extensions',
|
||||
'category.bundles': 'Bundles',
|
||||
'category.triggers': 'Triggers',
|
||||
'categorySingle.model': 'Model',
|
||||
'categorySingle.tool': 'Tool',
|
||||
'categorySingle.datasource': 'Datasource',
|
||||
'categorySingle.agent': 'Agent',
|
||||
'categorySingle.extension': 'Extension',
|
||||
'categorySingle.bundle': 'Bundle',
|
||||
'categorySingle.trigger': 'Trigger',
|
||||
'menus.plugins': 'Plugins',
|
||||
'menus.exploreMarketplace': 'Explore Marketplace',
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mockT,
|
||||
}),
|
||||
}))
|
||||
import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from '../hooks'
|
||||
|
||||
describe('useTags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockT.mockClear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -65,13 +16,12 @@ describe('useTags', () => {
|
||||
expect(result.current.tags.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should call translation function for each tag', () => {
|
||||
renderHook(() => useTags())
|
||||
it('should return tags with translated labels', () => {
|
||||
const { result } = renderHook(() => useTags())
|
||||
|
||||
// Verify t() was called for tag translations
|
||||
expect(mockT).toHaveBeenCalled()
|
||||
const tagCalls = mockT.mock.calls.filter(call => call[0].startsWith('tags.'))
|
||||
expect(tagCalls.length).toBeGreaterThan(0)
|
||||
result.current.tags.forEach((tag) => {
|
||||
expect(tag.label).toBe(`pluginTags.tags.${tag.name}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return tags with name and label properties', () => {
|
||||
@@ -99,7 +49,7 @@ describe('useTags', () => {
|
||||
|
||||
expect(result.current.tagsMap.agent).toBeDefined()
|
||||
expect(result.current.tagsMap.agent.name).toBe('agent')
|
||||
expect(result.current.tagsMap.agent.label).toBe('Agent')
|
||||
expect(result.current.tagsMap.agent.label).toBe('pluginTags.tags.agent')
|
||||
})
|
||||
|
||||
it('should contain all tags from tags array', () => {
|
||||
@@ -116,9 +66,8 @@ describe('useTags', () => {
|
||||
it('should return label for existing tag', () => {
|
||||
const { result } = renderHook(() => useTags())
|
||||
|
||||
// Test existing tags - this covers the branch where tagsMap[name] exists
|
||||
expect(result.current.getTagLabel('agent')).toBe('Agent')
|
||||
expect(result.current.getTagLabel('search')).toBe('Search')
|
||||
expect(result.current.getTagLabel('agent')).toBe('pluginTags.tags.agent')
|
||||
expect(result.current.getTagLabel('search')).toBe('pluginTags.tags.search')
|
||||
})
|
||||
|
||||
it('should return name for non-existing tag', () => {
|
||||
@@ -132,11 +81,9 @@ describe('useTags', () => {
|
||||
it('should cover both branches of getTagLabel conditional', () => {
|
||||
const { result } = renderHook(() => useTags())
|
||||
|
||||
// Branch 1: tag exists in tagsMap - returns label
|
||||
const existingTagResult = result.current.getTagLabel('rag')
|
||||
expect(existingTagResult).toBe('RAG')
|
||||
expect(existingTagResult).toBe('pluginTags.tags.rag')
|
||||
|
||||
// Branch 2: tag does not exist in tagsMap - returns name itself
|
||||
const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz')
|
||||
expect(nonExistingTagResult).toBe('unknown-tag-xyz')
|
||||
})
|
||||
@@ -150,23 +97,22 @@ describe('useTags', () => {
|
||||
it('should return correct labels for all predefined tags', () => {
|
||||
const { result } = renderHook(() => useTags())
|
||||
|
||||
// Test all predefined tags
|
||||
expect(result.current.getTagLabel('rag')).toBe('RAG')
|
||||
expect(result.current.getTagLabel('image')).toBe('Image')
|
||||
expect(result.current.getTagLabel('videos')).toBe('Videos')
|
||||
expect(result.current.getTagLabel('weather')).toBe('Weather')
|
||||
expect(result.current.getTagLabel('finance')).toBe('Finance')
|
||||
expect(result.current.getTagLabel('design')).toBe('Design')
|
||||
expect(result.current.getTagLabel('travel')).toBe('Travel')
|
||||
expect(result.current.getTagLabel('social')).toBe('Social')
|
||||
expect(result.current.getTagLabel('news')).toBe('News')
|
||||
expect(result.current.getTagLabel('medical')).toBe('Medical')
|
||||
expect(result.current.getTagLabel('productivity')).toBe('Productivity')
|
||||
expect(result.current.getTagLabel('education')).toBe('Education')
|
||||
expect(result.current.getTagLabel('business')).toBe('Business')
|
||||
expect(result.current.getTagLabel('entertainment')).toBe('Entertainment')
|
||||
expect(result.current.getTagLabel('utilities')).toBe('Utilities')
|
||||
expect(result.current.getTagLabel('other')).toBe('Other')
|
||||
expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag')
|
||||
expect(result.current.getTagLabel('image')).toBe('pluginTags.tags.image')
|
||||
expect(result.current.getTagLabel('videos')).toBe('pluginTags.tags.videos')
|
||||
expect(result.current.getTagLabel('weather')).toBe('pluginTags.tags.weather')
|
||||
expect(result.current.getTagLabel('finance')).toBe('pluginTags.tags.finance')
|
||||
expect(result.current.getTagLabel('design')).toBe('pluginTags.tags.design')
|
||||
expect(result.current.getTagLabel('travel')).toBe('pluginTags.tags.travel')
|
||||
expect(result.current.getTagLabel('social')).toBe('pluginTags.tags.social')
|
||||
expect(result.current.getTagLabel('news')).toBe('pluginTags.tags.news')
|
||||
expect(result.current.getTagLabel('medical')).toBe('pluginTags.tags.medical')
|
||||
expect(result.current.getTagLabel('productivity')).toBe('pluginTags.tags.productivity')
|
||||
expect(result.current.getTagLabel('education')).toBe('pluginTags.tags.education')
|
||||
expect(result.current.getTagLabel('business')).toBe('pluginTags.tags.business')
|
||||
expect(result.current.getTagLabel('entertainment')).toBe('pluginTags.tags.entertainment')
|
||||
expect(result.current.getTagLabel('utilities')).toBe('pluginTags.tags.utilities')
|
||||
expect(result.current.getTagLabel('other')).toBe('pluginTags.tags.other')
|
||||
})
|
||||
|
||||
it('should handle empty string tag name', () => {
|
||||
@@ -255,27 +201,27 @@ describe('useCategories', () => {
|
||||
it('should use plural labels when isSingle is false', () => {
|
||||
const { result } = renderHook(() => useCategories(false))
|
||||
|
||||
expect(result.current.categoriesMap.tool.label).toBe('Tools')
|
||||
expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools')
|
||||
})
|
||||
|
||||
it('should use plural labels when isSingle is undefined', () => {
|
||||
const { result } = renderHook(() => useCategories())
|
||||
|
||||
expect(result.current.categoriesMap.tool.label).toBe('Tools')
|
||||
expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools')
|
||||
})
|
||||
|
||||
it('should use singular labels when isSingle is true', () => {
|
||||
const { result } = renderHook(() => useCategories(true))
|
||||
|
||||
expect(result.current.categoriesMap.tool.label).toBe('Tool')
|
||||
expect(result.current.categoriesMap.tool.label).toBe('plugin.categorySingle.tool')
|
||||
})
|
||||
|
||||
it('should handle agent category specially', () => {
|
||||
const { result: resultPlural } = renderHook(() => useCategories(false))
|
||||
const { result: resultSingle } = renderHook(() => useCategories(true))
|
||||
|
||||
expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('Agents')
|
||||
expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('Agent')
|
||||
expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents')
|
||||
expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -298,7 +244,6 @@ describe('useCategories', () => {
|
||||
describe('usePluginPageTabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockT.mockClear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -326,12 +271,11 @@ describe('usePluginPageTabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call translation function for tab texts', () => {
|
||||
renderHook(() => usePluginPageTabs())
|
||||
it('should return tabs with translated texts', () => {
|
||||
const { result } = renderHook(() => usePluginPageTabs())
|
||||
|
||||
// Verify t() was called for menu translations
|
||||
expect(mockT).toHaveBeenCalledWith('menus.plugins', { ns: 'common' })
|
||||
expect(mockT).toHaveBeenCalledWith('menus.exploreMarketplace', { ns: 'common' })
|
||||
expect(result.current[0].text).toBe('common.menus.plugins')
|
||||
expect(result.current[1].text).toBe('common.menus.exploreMarketplace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -342,7 +286,7 @@ describe('usePluginPageTabs', () => {
|
||||
const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins)
|
||||
expect(pluginsTab).toBeDefined()
|
||||
expect(pluginsTab?.value).toBe('plugins')
|
||||
expect(pluginsTab?.text).toBe('Plugins')
|
||||
expect(pluginsTab?.text).toBe('common.menus.plugins')
|
||||
})
|
||||
|
||||
it('should have marketplace tab with correct value', () => {
|
||||
@@ -351,7 +295,7 @@ describe('usePluginPageTabs', () => {
|
||||
const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace)
|
||||
expect(marketplaceTab).toBeDefined()
|
||||
expect(marketplaceTab?.value).toBe('discover')
|
||||
expect(marketplaceTab?.text).toBe('Explore Marketplace')
|
||||
expect(marketplaceTab?.text).toBe('common.menus.exploreMarketplace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -360,14 +304,14 @@ describe('usePluginPageTabs', () => {
|
||||
const { result } = renderHook(() => usePluginPageTabs())
|
||||
|
||||
expect(result.current[0].value).toBe('plugins')
|
||||
expect(result.current[0].text).toBe('Plugins')
|
||||
expect(result.current[0].text).toBe('common.menus.plugins')
|
||||
})
|
||||
|
||||
it('should return marketplace tab as second tab', () => {
|
||||
const { result } = renderHook(() => usePluginPageTabs())
|
||||
|
||||
expect(result.current[1].value).toBe('discover')
|
||||
expect(result.current[1].text).toBe('Explore Marketplace')
|
||||
expect(result.current[1].text).toBe('common.menus.exploreMarketplace')
|
||||
})
|
||||
})
|
||||
|
||||
50
web/app/components/plugins/__tests__/utils.spec.ts
Normal file
50
web/app/components/plugins/__tests__/utils.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { TagKey } from '../constants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { getValidCategoryKeys, getValidTagKeys } from '../utils'
|
||||
|
||||
describe('plugins/utils', () => {
|
||||
describe('getValidTagKeys', () => {
|
||||
it('returns only valid tag keys from the predefined set', () => {
|
||||
const input = ['agent', 'rag', 'invalid-tag', 'search'] as TagKey[]
|
||||
const result = getValidTagKeys(input)
|
||||
expect(result).toEqual(['agent', 'rag', 'search'])
|
||||
})
|
||||
|
||||
it('returns empty array when no valid tags', () => {
|
||||
const result = getValidTagKeys(['foo', 'bar'] as unknown as TagKey[])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(getValidTagKeys([])).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves all valid tags when all are valid', () => {
|
||||
const input: TagKey[] = ['agent', 'rag', 'search', 'image']
|
||||
const result = getValidTagKeys(input)
|
||||
expect(result).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getValidCategoryKeys', () => {
|
||||
it('returns matching category for valid key', () => {
|
||||
expect(getValidCategoryKeys(PluginCategoryEnum.model)).toBe(PluginCategoryEnum.model)
|
||||
expect(getValidCategoryKeys(PluginCategoryEnum.tool)).toBe(PluginCategoryEnum.tool)
|
||||
expect(getValidCategoryKeys(PluginCategoryEnum.agent)).toBe(PluginCategoryEnum.agent)
|
||||
expect(getValidCategoryKeys('bundle')).toBe('bundle')
|
||||
})
|
||||
|
||||
it('returns undefined for invalid category', () => {
|
||||
expect(getValidCategoryKeys('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(getValidCategoryKeys(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(getValidCategoryKeys('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DeprecationNotice from '../deprecation-notice'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
|
||||
<a data-testid="link" href={href}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DeprecationNotice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('returns null when status is not "deleted"', () => {
|
||||
const { container } = render(
|
||||
<DeprecationNotice
|
||||
status="active"
|
||||
deprecatedReason="business_adjustments"
|
||||
alternativePluginId="alt-plugin"
|
||||
alternativePluginURL="/plugins/alt-plugin"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders deprecation notice when status is "deleted"', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason=""
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('plugin.detailPanel.deprecation.noReason')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with valid reason and alternative plugin', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason="business_adjustments"
|
||||
alternativePluginId="better-plugin"
|
||||
alternativePluginURL="/plugins/better-plugin"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('detailPanel.deprecation.fullMessage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders only reason without alternative plugin', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason="no_maintainer"
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/plugin\.detailPanel\.deprecation\.onlyReason/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no-reason message for invalid reason', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason="unknown_reason"
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('plugin.detailPanel.deprecation.noReason')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason=""
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
className="my-custom-class"
|
||||
/>,
|
||||
)
|
||||
expect((container.firstChild as HTMLElement).className).toContain('my-custom-class')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import KeyValueItem from '../key-value-item'
|
||||
|
||||
vi.mock('../../../base/icons/src/vender/line/files', () => ({
|
||||
CopyCheck: () => <span data-testid="copy-check-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/action-button', () => ({
|
||||
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<button data-testid="action-button" onClick={onClick}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
}))
|
||||
|
||||
describe('KeyValueItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders label and value', () => {
|
||||
render(<KeyValueItem label="ID" value="abc-123" />)
|
||||
expect(screen.getByText('ID')).toBeInTheDocument()
|
||||
expect(screen.getByText('abc-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders maskedValue instead of value when provided', () => {
|
||||
render(<KeyValueItem label="Key" value="sk-secret" maskedValue="sk-***" />)
|
||||
expect(screen.getByText('sk-***')).toBeInTheDocument()
|
||||
expect(screen.queryByText('sk-secret')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies actual value (not masked) when copy button is clicked', () => {
|
||||
render(<KeyValueItem label="Key" value="sk-secret" maskedValue="sk-***" />)
|
||||
fireEvent.click(screen.getByTestId('action-button'))
|
||||
expect(mockCopy).toHaveBeenCalledWith('sk-secret')
|
||||
})
|
||||
|
||||
it('renders copy tooltip', () => {
|
||||
render(<KeyValueItem label="ID" value="123" />)
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.operation.copy')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Theme } from '@/types/app'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
import IconWithTooltip from '../icon-with-tooltip'
|
||||
|
||||
// Mock Tooltip component
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
@@ -2,7 +2,7 @@ import type { ComponentProps } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Theme } from '@/types/app'
|
||||
import Partner from './partner'
|
||||
import Partner from '../partner'
|
||||
|
||||
// Mock useTheme hook
|
||||
const mockUseTheme = vi.fn()
|
||||
@@ -11,9 +11,9 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
}))
|
||||
|
||||
// Mock IconWithTooltip to directly test Partner's behavior
|
||||
type IconWithTooltipProps = ComponentProps<typeof import('./icon-with-tooltip').default>
|
||||
type IconWithTooltipProps = ComponentProps<typeof import('../icon-with-tooltip').default>
|
||||
const mockIconWithTooltip = vi.fn()
|
||||
vi.mock('./icon-with-tooltip', () => ({
|
||||
vi.mock('../icon-with-tooltip', () => ({
|
||||
default: (props: IconWithTooltipProps) => {
|
||||
mockIconWithTooltip(props)
|
||||
const { theme, BadgeIconLight, BadgeIconDark, className, popupContent } = props
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/plugins/VerifiedDark', () => ({
|
||||
default: () => <span data-testid="verified-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/plugins/VerifiedLight', () => ({
|
||||
default: () => <span data-testid="verified-light" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('../icon-with-tooltip', () => ({
|
||||
default: ({ popupContent, BadgeIconLight, BadgeIconDark, theme }: {
|
||||
popupContent: string
|
||||
BadgeIconLight: React.FC
|
||||
BadgeIconDark: React.FC
|
||||
theme: string
|
||||
[key: string]: unknown
|
||||
}) => (
|
||||
<div data-testid="icon-with-tooltip" data-popup={popupContent}>
|
||||
{theme === 'light' ? <BadgeIconLight /> : <BadgeIconDark />}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Verified', () => {
|
||||
let Verified: (typeof import('../verified'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../verified')
|
||||
Verified = mod.default
|
||||
})
|
||||
|
||||
it('should render with tooltip text', () => {
|
||||
render(<Verified text="Verified Plugin" />)
|
||||
|
||||
const tooltip = screen.getByTestId('icon-with-tooltip')
|
||||
expect(tooltip).toHaveAttribute('data-popup', 'Verified Plugin')
|
||||
})
|
||||
|
||||
it('should render light theme icon by default', () => {
|
||||
render(<Verified text="Verified" />)
|
||||
|
||||
expect(screen.getByTestId('verified-light')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,7 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
iconWrapperClassName,
|
||||
textClassName,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
const deprecatedReasonKey = useMemo(() => {
|
||||
if (!deprecatedReason)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CardMoreInfo from '../card-more-info'
|
||||
|
||||
vi.mock('../base/download-count', () => ({
|
||||
default: ({ downloadCount }: { downloadCount: number }) => (
|
||||
<span data-testid="download-count">{downloadCount}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CardMoreInfo', () => {
|
||||
it('renders tags with # prefix', () => {
|
||||
render(<CardMoreInfo tags={['search', 'agent']} />)
|
||||
expect(screen.getByText('search')).toBeInTheDocument()
|
||||
expect(screen.getByText('agent')).toBeInTheDocument()
|
||||
// # prefixes
|
||||
const hashmarks = screen.getAllByText('#')
|
||||
expect(hashmarks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders download count when provided', () => {
|
||||
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
|
||||
expect(screen.getByTestId('download-count')).toHaveTextContent('1000')
|
||||
})
|
||||
|
||||
it('does not render download count when undefined', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
expect(screen.queryByTestId('download-count')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders separator between download count and tags', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={['test']} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render separator when no tags', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={[]} />)
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render separator when no download count', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty tags array', () => {
|
||||
const { container } = render(<CardMoreInfo tags={[]} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
589
web/app/components/plugins/card/__tests__/index.spec.tsx
Normal file
589
web/app/components/plugins/card/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
import type { Plugin } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import Card from '../index'
|
||||
|
||||
let mockTheme = 'light'
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: (obj: Record<string, string>, locale: string) => {
|
||||
return obj?.[locale] || obj?.['en-US'] || ''
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: (locale: string) => locale || 'en-US',
|
||||
}))
|
||||
|
||||
const mockCategoriesMap: Record<string, { label: string }> = {
|
||||
'tool': { label: 'Tool' },
|
||||
'model': { label: 'Model' },
|
||||
'extension': { label: 'Extension' },
|
||||
'agent-strategy': { label: 'Agent' },
|
||||
'datasource': { label: 'Datasource' },
|
||||
'trigger': { label: 'Trigger' },
|
||||
'bundle': { label: 'Bundle' },
|
||||
}
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useCategories: () => ({
|
||||
categoriesMap: mockCategoriesMap,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatNumber: (num: number) => num.toLocaleString(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mcp', () => ({
|
||||
shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ icon, background, innerIcon, size, iconType }: {
|
||||
icon?: string
|
||||
background?: string
|
||||
innerIcon?: React.ReactNode
|
||||
size?: string
|
||||
iconType?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="app-icon"
|
||||
data-icon={icon}
|
||||
data-background={background}
|
||||
data-size={size}
|
||||
data-icon-type={iconType}
|
||||
>
|
||||
{!!innerIcon && <div data-testid="inner-icon">{innerIcon}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
||||
Mcp: ({ className }: { className?: string }) => (
|
||||
<div data-testid="mcp-icon" className={className}>MCP</div>
|
||||
),
|
||||
Group: ({ className }: { className?: string }) => (
|
||||
<div data-testid="group-icon" className={className}>Group</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/icons/src/vender/plugin', () => ({
|
||||
LeftCorner: ({ className }: { className?: string }) => (
|
||||
<div data-testid="left-corner" className={className}>LeftCorner</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/badges/partner', () => ({
|
||||
default: ({ className, text }: { className?: string, text?: string }) => (
|
||||
<div data-testid="partner-badge" className={className} title={text}>Partner</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/badges/verified', () => ({
|
||||
default: ({ className, text }: { className?: string, text?: string }) => (
|
||||
<div data-testid="verified-badge" className={className} title={text}>Verified</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/skeleton', () => ({
|
||||
SkeletonContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="skeleton-container">{children}</div>
|
||||
),
|
||||
SkeletonPoint: () => <div data-testid="skeleton-point" />,
|
||||
SkeletonRectangle: ({ className }: { className?: string }) => (
|
||||
<div data-testid="skeleton-rectangle" className={className} />
|
||||
),
|
||||
SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => (
|
||||
<div data-testid="skeleton-row" className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'test-plugin',
|
||||
plugin_id: 'plugin-123',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-org/test-plugin:1.0.0',
|
||||
icon: '/test-icon.png',
|
||||
verified: false,
|
||||
label: { 'en-US': 'Test Plugin' },
|
||||
brief: { 'en-US': 'Test plugin description' },
|
||||
description: { 'en-US': 'Full test plugin description' },
|
||||
introduction: 'Test plugin introduction',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 1000,
|
||||
endpoint: { settings: [] },
|
||||
tags: [{ name: 'search' }],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Card', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin title from label', () => {
|
||||
const plugin = createMockPlugin({
|
||||
label: { 'en-US': 'My Plugin Title' },
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('My Plugin Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin description from brief', () => {
|
||||
const plugin = createMockPlugin({
|
||||
brief: { 'en-US': 'This is a brief description' },
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('This is a brief description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render organization info with org name and package name', () => {
|
||||
const plugin = createMockPlugin({
|
||||
org: 'my-org',
|
||||
name: 'my-plugin',
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('my-org')).toBeInTheDocument()
|
||||
expect(screen.getByText('my-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin icon', () => {
|
||||
const plugin = createMockPlugin({
|
||||
icon: '/custom-icon.png',
|
||||
})
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
// Check for background image style on icon element
|
||||
const iconElement = container.querySelector('[style*="background-image"]')
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use icon_dark when theme is dark and icon_dark is provided', () => {
|
||||
// Set theme to dark
|
||||
mockTheme = 'dark'
|
||||
|
||||
const plugin = createMockPlugin({
|
||||
icon: '/light-icon.png',
|
||||
icon_dark: '/dark-icon.png',
|
||||
})
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
// Check that icon uses dark icon
|
||||
const iconElement = container.querySelector('[style*="background-image"]')
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
expect(iconElement).toHaveStyle({ backgroundImage: 'url(/dark-icon.png)' })
|
||||
|
||||
// Reset theme
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should use icon when theme is dark but icon_dark is not provided', () => {
|
||||
mockTheme = 'dark'
|
||||
|
||||
const plugin = createMockPlugin({
|
||||
icon: '/light-icon.png',
|
||||
})
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
// Should fallback to light icon
|
||||
const iconElement = container.querySelector('[style*="background-image"]')
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
expect(iconElement).toHaveStyle({ backgroundImage: 'url(/light-icon.png)' })
|
||||
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render corner mark with category label', () => {
|
||||
const plugin = createMockPlugin({
|
||||
category: PluginCategoryEnum.tool,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Testing
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const plugin = createMockPlugin()
|
||||
const { container } = render(
|
||||
<Card payload={plugin} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide corner mark when hideCornerMark is true', () => {
|
||||
const plugin = createMockPlugin({
|
||||
category: PluginCategoryEnum.tool,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} hideCornerMark={true} />)
|
||||
|
||||
expect(screen.queryByTestId('left-corner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show corner mark by default', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('left-corner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass installed prop to Icon component', () => {
|
||||
const plugin = createMockPlugin()
|
||||
const { container } = render(<Card payload={plugin} installed={true} />)
|
||||
|
||||
expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass installFailed prop to Icon component', () => {
|
||||
const plugin = createMockPlugin()
|
||||
const { container } = render(<Card payload={plugin} installFailed={true} />)
|
||||
|
||||
expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when provided', () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(
|
||||
<Card payload={plugin} footer={<div data-testid="custom-footer">Footer Content</div>} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Footer Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render titleLeft when provided', () => {
|
||||
const plugin = createMockPlugin()
|
||||
render(
|
||||
<Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('title-left')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use custom descriptionLineRows', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
const { container } = render(
|
||||
<Card payload={plugin} descriptionLineRows={1} />,
|
||||
)
|
||||
|
||||
// Check for h-4 truncate class when descriptionLineRows is 1
|
||||
expect(container.querySelector('.h-4.truncate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default descriptionLineRows of 2', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
// Check for h-8 line-clamp-2 class when descriptionLineRows is 2 (default)
|
||||
expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Loading State Tests
|
||||
// ================================
|
||||
describe('Loading State', () => {
|
||||
it('should render Placeholder when isLoading is true', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
render(<Card payload={plugin} isLoading={true} loadingFileName="loading.txt" />)
|
||||
|
||||
// Should render skeleton elements
|
||||
expect(screen.getByTestId('skeleton-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loadingFileName in Placeholder', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
render(<Card payload={plugin} isLoading={true} loadingFileName="my-plugin.zip" />)
|
||||
|
||||
expect(screen.getByText('my-plugin.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render card content when loading', () => {
|
||||
const plugin = createMockPlugin({
|
||||
label: { 'en-US': 'Plugin Title' },
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} isLoading={true} loadingFileName="file.txt" />)
|
||||
|
||||
// Plugin content should not be visible during loading
|
||||
expect(screen.queryByText('Plugin Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render loading state by default', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.queryByTestId('skeleton-container')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Badges Tests
|
||||
// ================================
|
||||
describe('Badges', () => {
|
||||
it('should render Partner badge when badges includes partner', () => {
|
||||
const plugin = createMockPlugin({
|
||||
badges: ['partner'],
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Verified badge when verified is true', () => {
|
||||
const plugin = createMockPlugin({
|
||||
verified: true,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both Partner and Verified badges', () => {
|
||||
const plugin = createMockPlugin({
|
||||
badges: ['partner'],
|
||||
verified: true,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Partner badge when badges is empty', () => {
|
||||
const plugin = createMockPlugin({
|
||||
badges: [],
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Verified badge when verified is false', () => {
|
||||
const plugin = createMockPlugin({
|
||||
verified: false,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined badges gracefully', () => {
|
||||
const plugin = createMockPlugin()
|
||||
// @ts-expect-error - Testing undefined badges
|
||||
plugin.badges = undefined
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Limited Install Warning Tests
|
||||
// ================================
|
||||
describe('Limited Install Warning', () => {
|
||||
it('should render warning when limitedInstall is true', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
const { container } = render(<Card payload={plugin} limitedInstall={true} />)
|
||||
|
||||
expect(container.querySelector('.text-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render warning by default', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
expect(container.querySelector('.text-text-warning-secondary')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply limited padding when limitedInstall is true', () => {
|
||||
const plugin = createMockPlugin()
|
||||
|
||||
const { container } = render(<Card payload={plugin} limitedInstall={true} />)
|
||||
|
||||
expect(container.querySelector('.pb-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Category Type Tests
|
||||
// ================================
|
||||
describe('Category Types', () => {
|
||||
it('should display bundle label for bundle type', () => {
|
||||
const plugin = createMockPlugin({
|
||||
type: 'bundle',
|
||||
category: PluginCategoryEnum.tool,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
// For bundle type, should show 'Bundle' instead of category
|
||||
expect(screen.getByText('Bundle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display category label for non-bundle types', () => {
|
||||
const plugin = createMockPlugin({
|
||||
type: 'plugin',
|
||||
category: PluginCategoryEnum.model,
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Memoization Tests
|
||||
// ================================
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
// Card is wrapped with React.memo
|
||||
expect(Card).toBeDefined()
|
||||
// The component should have the memo display name characteristic
|
||||
expect(typeof Card).toBe('object')
|
||||
})
|
||||
|
||||
it('should not re-render when props are the same', () => {
|
||||
const plugin = createMockPlugin()
|
||||
const renderCount = vi.fn()
|
||||
|
||||
const TestWrapper = ({ p }: { p: Plugin }) => {
|
||||
renderCount()
|
||||
return <Card payload={p} />
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestWrapper p={plugin} />)
|
||||
expect(renderCount).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Re-render with same plugin reference
|
||||
rerender(<TestWrapper p={plugin} />)
|
||||
expect(renderCount).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty label object', () => {
|
||||
const plugin = createMockPlugin({
|
||||
label: {},
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty brief object', () => {
|
||||
const plugin = createMockPlugin({
|
||||
brief: {},
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined label', () => {
|
||||
const plugin = createMockPlugin()
|
||||
// @ts-expect-error - Testing undefined label
|
||||
plugin.label = undefined
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in plugin name', () => {
|
||||
const plugin = createMockPlugin({
|
||||
name: 'plugin-with-special-chars!@#$%',
|
||||
org: 'org<script>alert(1)</script>',
|
||||
})
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long title', () => {
|
||||
const longTitle = 'A'.repeat(500)
|
||||
const plugin = createMockPlugin({
|
||||
label: { 'en-US': longTitle },
|
||||
})
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
// Should have truncate class for long text
|
||||
expect(container.querySelector('.truncate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long description', () => {
|
||||
const longDescription = 'B'.repeat(1000)
|
||||
const plugin = createMockPlugin({
|
||||
brief: { 'en-US': longDescription },
|
||||
})
|
||||
|
||||
const { container } = render(<Card payload={plugin} />)
|
||||
|
||||
// Should have line-clamp class for long text
|
||||
expect(container.querySelector('.line-clamp-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Icon from '../card-icon'
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ icon, background }: { icon: string, background: string }) => (
|
||||
<div data-testid="app-icon" data-icon={icon} data-bg={background} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
||||
Mcp: () => <span data-testid="mcp-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mcp', () => ({
|
||||
shouldUseMcpIcon: () => false,
|
||||
}))
|
||||
|
||||
describe('Icon', () => {
|
||||
it('renders string src as background image', () => {
|
||||
const { container } = render(<Icon src="https://example.com/icon.png" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el.style.backgroundImage).toContain('https://example.com/icon.png')
|
||||
})
|
||||
|
||||
it('renders emoji src using AppIcon', () => {
|
||||
render(<Icon src={{ content: '🔍', background: '#fff' }} />)
|
||||
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon', '🔍')
|
||||
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-bg', '#fff')
|
||||
})
|
||||
|
||||
it('shows check icon when installed', () => {
|
||||
const { container } = render(<Icon src="icon.png" installed />)
|
||||
expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows close icon when installFailed', () => {
|
||||
const { container } = render(<Icon src="icon.png" installFailed />)
|
||||
expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show status icons by default', () => {
|
||||
const { container } = render(<Icon src="icon.png" />)
|
||||
expect(container.querySelector('.bg-state-success-solid')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-state-destructive-solid')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<Icon src="icon.png" className="my-class" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el.className).toContain('my-class')
|
||||
})
|
||||
|
||||
it('applies correct size class', () => {
|
||||
const { container } = render(<Icon src="icon.png" size="small" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el.className).toContain('w-8')
|
||||
expect(el.className).toContain('h-8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CornerMark from '../corner-mark'
|
||||
|
||||
vi.mock('../../../../base/icons/src/vender/plugin', () => ({
|
||||
LeftCorner: ({ className }: { className: string }) => <svg data-testid="left-corner" className={className} />,
|
||||
}))
|
||||
|
||||
describe('CornerMark', () => {
|
||||
it('renders the text content', () => {
|
||||
render(<CornerMark text="NEW" />)
|
||||
expect(screen.getByText('NEW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the LeftCorner icon', () => {
|
||||
render(<CornerMark text="BETA" />)
|
||||
expect(screen.getByTestId('left-corner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with absolute positioning', () => {
|
||||
const { container } = render(<CornerMark text="TAG" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('absolute')
|
||||
expect(wrapper.className).toContain('right-0')
|
||||
expect(wrapper.className).toContain('top-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Description from '../description'
|
||||
|
||||
describe('Description', () => {
|
||||
it('renders description text', () => {
|
||||
render(<Description text="A great plugin" descriptionLineRows={1} />)
|
||||
expect(screen.getByText('A great plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies truncate class for 1 line', () => {
|
||||
render(<Description text="Single line" descriptionLineRows={1} />)
|
||||
const el = screen.getByText('Single line')
|
||||
expect(el.className).toContain('truncate')
|
||||
expect(el.className).toContain('h-4')
|
||||
})
|
||||
|
||||
it('applies line-clamp-2 class for 2 lines', () => {
|
||||
render(<Description text="Two lines" descriptionLineRows={2} />)
|
||||
const el = screen.getByText('Two lines')
|
||||
expect(el.className).toContain('line-clamp-2')
|
||||
expect(el.className).toContain('h-8')
|
||||
})
|
||||
|
||||
it('applies line-clamp-3 class for 3 lines', () => {
|
||||
render(<Description text="Three lines" descriptionLineRows={3} />)
|
||||
const el = screen.getByText('Three lines')
|
||||
expect(el.className).toContain('line-clamp-3')
|
||||
expect(el.className).toContain('h-12')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Description text="test" descriptionLineRows={1} className="mt-2" />)
|
||||
const el = screen.getByText('test')
|
||||
expect(el.className).toContain('mt-2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DownloadCount from '../download-count'
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatNumber: (n: number) => {
|
||||
if (n >= 1000)
|
||||
return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('DownloadCount', () => {
|
||||
it('renders formatted download count', () => {
|
||||
render(<DownloadCount downloadCount={1500} />)
|
||||
expect(screen.getByText('1.5k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders small numbers directly', () => {
|
||||
render(<DownloadCount downloadCount={42} />)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero download count', () => {
|
||||
render(<DownloadCount downloadCount={0} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import OrgInfo from '../org-info'
|
||||
|
||||
describe('OrgInfo', () => {
|
||||
it('renders package name', () => {
|
||||
render(<OrgInfo packageName="my-plugin" />)
|
||||
expect(screen.getByText('my-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders org name with separator when provided', () => {
|
||||
render(<OrgInfo orgName="dify" packageName="search-tool" />)
|
||||
expect(screen.getByText('dify')).toBeInTheDocument()
|
||||
expect(screen.getByText('/')).toBeInTheDocument()
|
||||
expect(screen.getByText('search-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render org name or separator when orgName is not provided', () => {
|
||||
render(<OrgInfo packageName="standalone" />)
|
||||
expect(screen.queryByText('/')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('standalone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<OrgInfo packageName="pkg" className="custom-class" />)
|
||||
expect((container.firstChild as HTMLElement).className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('applies packageNameClassName to package name element', () => {
|
||||
render(<OrgInfo packageName="pkg" packageNameClassName="w-auto" />)
|
||||
const pkgEl = screen.getByText('pkg')
|
||||
expect(pkgEl.className).toContain('w-auto')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../title', () => ({
|
||||
default: ({ title }: { title: string }) => <span data-testid="title">{title}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../base/icons/src/vender/other', () => ({
|
||||
Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
describe('Placeholder', () => {
|
||||
let Placeholder: (typeof import('../placeholder'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../placeholder')
|
||||
Placeholder = mod.default
|
||||
})
|
||||
|
||||
it('should render skeleton rows', () => {
|
||||
const { container } = render(<Placeholder wrapClassName="w-full" />)
|
||||
|
||||
expect(container.querySelectorAll('.gap-2').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render group icon placeholder', () => {
|
||||
render(<Placeholder wrapClassName="w-full" />)
|
||||
|
||||
expect(screen.getByTestId('group-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading filename when provided', () => {
|
||||
render(<Placeholder wrapClassName="w-full" loadingFileName="test-plugin.zip" />)
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('test-plugin.zip')
|
||||
})
|
||||
|
||||
it('should render skeleton rectangles when no filename', () => {
|
||||
const { container } = render(<Placeholder wrapClassName="w-full" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadingPlaceholder', () => {
|
||||
let LoadingPlaceholder: (typeof import('../placeholder'))['LoadingPlaceholder']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../placeholder')
|
||||
LoadingPlaceholder = mod.LoadingPlaceholder
|
||||
})
|
||||
|
||||
it('should render as a simple div with background', () => {
|
||||
const { container } = render(<LoadingPlaceholder />)
|
||||
|
||||
expect(container.firstChild).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should accept className prop', () => {
|
||||
const { container } = render(<LoadingPlaceholder className="mt-3 w-[420px]" />)
|
||||
|
||||
expect(container.firstChild).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Title from '../title'
|
||||
|
||||
describe('Title', () => {
|
||||
it('renders the title text', () => {
|
||||
render(<Title title="Test Plugin" />)
|
||||
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with truncate class for long text', () => {
|
||||
render(<Title title="A very long title that should be truncated" />)
|
||||
const el = screen.getByText('A very long title that should be truncated')
|
||||
expect(el.className).toContain('truncate')
|
||||
})
|
||||
|
||||
it('renders empty string without error', () => {
|
||||
const { container } = render(<Title title="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiInstallLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
@@ -9,13 +9,10 @@ type Props = {
|
||||
const DownloadCountComponent = ({
|
||||
downloadCount,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
return (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{formatNumber(downloadCount)}
|
||||
{' '}
|
||||
{t('marketplace.installs')}
|
||||
<div className="flex items-center space-x-1 text-text-tertiary">
|
||||
<RiInstallLine className="h-3 w-3 shrink-0" />
|
||||
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import DownloadCount from './download-count'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
orgName?: string
|
||||
packageName?: string
|
||||
packageName: string
|
||||
packageNameClassName?: string
|
||||
downloadCount?: number
|
||||
linkToOrg?: boolean
|
||||
}
|
||||
|
||||
const OrgInfo = ({
|
||||
@@ -16,42 +12,7 @@ const OrgInfo = ({
|
||||
orgName,
|
||||
packageName,
|
||||
packageNameClassName,
|
||||
downloadCount,
|
||||
linkToOrg = true,
|
||||
}: Props) => {
|
||||
// New format: "by {orgName} · {downloadCount} installs" (for marketplace cards)
|
||||
if (downloadCount !== undefined) {
|
||||
return (
|
||||
<div className={cn('system-xs-regular flex h-4 items-center gap-2 text-text-tertiary', className)}>
|
||||
{orgName && (
|
||||
<span className="shrink-0">
|
||||
<span className="mr-1 text-text-tertiary">by</span>
|
||||
{linkToOrg
|
||||
? (
|
||||
<Link
|
||||
href={`/creators/${orgName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-text-secondary hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{orgName}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<span className="text-text-tertiary">
|
||||
{orgName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0">·</span>
|
||||
<DownloadCount downloadCount={downloadCount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy format: "{orgName} / {packageName}" (for plugin detail panels)
|
||||
return (
|
||||
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
|
||||
{orgName && (
|
||||
@@ -60,11 +21,9 @@ const OrgInfo = ({
|
||||
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
|
||||
</>
|
||||
)}
|
||||
{packageName && (
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
40
web/app/components/plugins/card/card-more-info.tsx
Normal file
40
web/app/components/plugins/card/card-more-info.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react'
|
||||
import DownloadCount from './base/download-count'
|
||||
|
||||
type Props = {
|
||||
downloadCount?: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardMoreInfoComponent = ({
|
||||
downloadCount,
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex h-5 items-center">
|
||||
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
|
||||
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
|
||||
{tags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
|
||||
title={`# ${tag}`}
|
||||
>
|
||||
<span className="text-text-quaternary">#</span>
|
||||
<span className="truncate text-text-tertiary">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardMoreInfo = React.memo(CardMoreInfoComponent)
|
||||
|
||||
export default CardMoreInfo
|
||||
@@ -1,34 +0,0 @@
|
||||
import { RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardTagsComponent = ({
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="mt-2 flex min-h-[20px] items-center gap-1">
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 overflow-hidden">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex max-w-[100px] items-center gap-0.5 truncate rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
|
||||
title={tag}
|
||||
>
|
||||
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">{tag.toUpperCase()}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardTags = React.memo(CardTagsComponent)
|
||||
|
||||
export default CardTags
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,6 @@ export type Props = {
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
limitedInstall?: boolean
|
||||
disableOrgLink?: boolean
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
@@ -47,12 +46,11 @@ const Card = ({
|
||||
isLoading = false,
|
||||
loadingFileName,
|
||||
limitedInstall = false,
|
||||
disableOrgLink = false,
|
||||
}: Props) => {
|
||||
const locale = useGetLanguage()
|
||||
const { t } = useTranslation()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const { category, type, org, label, brief, icon, icon_dark, verified, badges = [], install_count } = payload
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
@@ -88,8 +86,7 @@ const Card = ({
|
||||
<OrgInfo
|
||||
className="mt-0.5"
|
||||
orgName={org}
|
||||
downloadCount={install_count}
|
||||
linkToOrg={!disableOrgLink}
|
||||
packageName={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useGitHubReleases, useGitHubUpload } from '../hooks'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (...args: unknown[]) => mockNotify(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
GITHUB_ACCESS_TOKEN: '',
|
||||
}))
|
||||
|
||||
const mockUploadGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const va = parseVersion(a)
|
||||
const vb = parseVersion(b)
|
||||
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
||||
const diff = (va[i] || 0) - (vb[i] || 0)
|
||||
if (diff > 0)
|
||||
return 1
|
||||
if (diff < 0)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number)
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0)
|
||||
if (diff !== 0)
|
||||
return diff
|
||||
}
|
||||
return 0
|
||||
}).pop()!
|
||||
},
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
globalThis.fetch = mockFetch
|
||||
|
||||
describe('install-plugin/hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useGitHubReleases', () => {
|
||||
describe('fetchReleases', () => {
|
||||
it('fetches releases from GitHub API and formats them', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://example.com/v1.zip', name: 'plugin.zip' }],
|
||||
body: 'Release notes',
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = await result.current.fetchReleases('owner', 'repo')
|
||||
|
||||
expect(releases).toHaveLength(1)
|
||||
expect(releases[0].tag_name).toBe('v1.0.0')
|
||||
expect(releases[0].assets[0].name).toBe('plugin.zip')
|
||||
expect(releases[0]).not.toHaveProperty('body')
|
||||
})
|
||||
|
||||
it('returns empty array and shows toast on fetch error', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = await result.current.fetchReleases('owner', 'repo')
|
||||
|
||||
expect(releases).toEqual([])
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForUpdates', () => {
|
||||
it('detects newer version available', () => {
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = [
|
||||
{ tag_name: 'v1.0.0', assets: [] },
|
||||
{ tag_name: 'v2.0.0', assets: [] },
|
||||
]
|
||||
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(true)
|
||||
expect(toastProps.message).toContain('v2.0.0')
|
||||
})
|
||||
|
||||
it('returns no update when current is latest', () => {
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = [
|
||||
{ tag_name: 'v1.0.0', assets: [] },
|
||||
]
|
||||
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('info')
|
||||
})
|
||||
|
||||
it('returns error for empty releases', () => {
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const { needUpdate, toastProps } = result.current.checkForUpdates([], 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('error')
|
||||
expect(toastProps.message).toContain('empty')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGitHubUpload', () => {
|
||||
it('uploads successfully and calls onSuccess', async () => {
|
||||
const mockManifest = { name: 'test-plugin' }
|
||||
mockUploadGitHub.mockResolvedValue({
|
||||
manifest: mockManifest,
|
||||
unique_identifier: 'uid-123',
|
||||
})
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useGitHubUpload())
|
||||
const pkg = await result.current.handleUpload(
|
||||
'https://github.com/owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.difypkg',
|
||||
onSuccess,
|
||||
)
|
||||
|
||||
expect(mockUploadGitHub).toHaveBeenCalledWith(
|
||||
'https://github.com/owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.difypkg',
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
manifest: mockManifest,
|
||||
unique_identifier: 'uid-123',
|
||||
})
|
||||
expect(pkg.unique_identifier).toBe('uid-123')
|
||||
})
|
||||
|
||||
it('shows toast on upload error', async () => {
|
||||
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
const { result } = renderHook(() => useGitHubUpload())
|
||||
await expect(
|
||||
result.current.handleUpload('url', 'v1', 'pkg'),
|
||||
).rejects.toThrow('Upload failed')
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PluginDeclaration, PluginManifestInMarket } from '../types'
|
||||
import type { PluginDeclaration, PluginManifestInMarket } from '../../types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import {
|
||||
convertRepoToUrl,
|
||||
parseGitHubUrl,
|
||||
pluginManifestInMarketToPluginProps,
|
||||
pluginManifestToCardPluginProps,
|
||||
} from './utils'
|
||||
} from '../utils'
|
||||
|
||||
// Mock es-toolkit/compat
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TaskStatus } from '../../../types'
|
||||
import checkTaskStatus from '../check-task-status'
|
||||
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
checkTaskStatus: (...args: unknown[]) => mockCheckTaskStatus(...args),
|
||||
}))
|
||||
|
||||
// Mock sleep to avoid actual waiting in tests
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('checkTaskStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns success when plugin status is success', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.success)
|
||||
})
|
||||
|
||||
it('returns failed when plugin status is failed', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.failed, message: 'Install failed' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.failed)
|
||||
expect(result.error).toBe('Install failed')
|
||||
})
|
||||
|
||||
it('returns failed when plugin is not found in task', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'other-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.failed)
|
||||
expect(result.error).toBe('Plugin package not found')
|
||||
})
|
||||
|
||||
it('polls recursively when status is running, then resolves on success', async () => {
|
||||
let callCount = 0
|
||||
mockCheckTaskStatus.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount < 3) {
|
||||
return Promise.resolve({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.running, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.success)
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stop() causes early return with success', async () => {
|
||||
const { check, stop } = checkTaskStatus()
|
||||
stop()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.success)
|
||||
expect(mockCheckTaskStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns different instances with independent state', async () => {
|
||||
const checker1 = checkTaskStatus()
|
||||
const checker2 = checkTaskStatus()
|
||||
|
||||
checker1.stop()
|
||||
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const result1 = await checker1.check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
const result2 = await checker2.check({ taskId: 'task-2', pluginUniqueIdentifier: 'test-plugin' })
|
||||
|
||||
expect(result1.status).toBe(TaskStatus.success)
|
||||
expect(result2.status).toBe(TaskStatus.success)
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ installed, installFailed, titleLeft }: { installed: boolean, installFailed: boolean, titleLeft?: React.ReactNode }) => (
|
||||
<div data-testid="card" data-installed={installed} data-failed={installFailed}>{titleLeft}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
pluginManifestInMarketToPluginProps: (p: unknown) => p,
|
||||
pluginManifestToCardPluginProps: (p: unknown) => p,
|
||||
}))
|
||||
|
||||
describe('Installed', () => {
|
||||
let Installed: (typeof import('../installed'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../installed')
|
||||
Installed = mod.default
|
||||
})
|
||||
|
||||
it('should render success message when not failed', () => {
|
||||
render(<Installed isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installedSuccessfullyDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render failure message when failed', () => {
|
||||
render(<Installed isFailed={true} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.installFailedDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom error message when provided', () => {
|
||||
render(<Installed isFailed={true} errMsg="Custom error" onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Custom error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render card with payload', () => {
|
||||
const payload = { version: '1.0.0', name: 'test-plugin' } as never
|
||||
render(<Installed payload={payload} isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
const card = screen.getByTestId('card')
|
||||
expect(card).toHaveAttribute('data-installed', 'true')
|
||||
expect(card).toHaveAttribute('data-failed', 'false')
|
||||
})
|
||||
|
||||
it('should render card as failed when isFailed', () => {
|
||||
const payload = { version: '1.0.0', name: 'test-plugin' } as never
|
||||
render(<Installed payload={payload} isFailed={true} onCancel={vi.fn()} />)
|
||||
|
||||
const card = screen.getByTestId('card')
|
||||
expect(card).toHaveAttribute('data-installed', 'false')
|
||||
expect(card).toHaveAttribute('data-failed', 'true')
|
||||
})
|
||||
|
||||
it('should call onCancel when close button clicked', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<Installed isFailed={false} onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.close'))
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show version badge in card', () => {
|
||||
const payload = { version: '1.0.0', name: 'test-plugin' } as never
|
||||
render(<Installed payload={payload} isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render card when no payload', () => {
|
||||
render(<Installed isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
|
||||
LoadingPlaceholder: () => <div data-testid="loading-placeholder" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../base/icons/src/vender/other', () => ({
|
||||
Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />,
|
||||
}))
|
||||
|
||||
describe('LoadingError', () => {
|
||||
let LoadingError: React.FC
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../loading-error')
|
||||
LoadingError = mod.default
|
||||
})
|
||||
|
||||
it('should render error message', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByText('plugin.installModal.pluginLoadError')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.installModal.pluginLoadErrorDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled checkbox', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByTestId('checkbox-undefined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error icon with close indicator', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByTestId('group-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading placeholder', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../../card/base/placeholder', () => ({
|
||||
default: () => <div data-testid="placeholder" />,
|
||||
}))
|
||||
|
||||
describe('Loading', () => {
|
||||
let Loading: React.FC
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../loading')
|
||||
Loading = mod.default
|
||||
})
|
||||
|
||||
it('should render disabled unchecked checkbox', () => {
|
||||
render(<Loading />)
|
||||
|
||||
expect(screen.getByTestId('checkbox-undefined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder', () => {
|
||||
render(<Loading />)
|
||||
|
||||
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Version', () => {
|
||||
let Version: (typeof import('../version'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../version')
|
||||
Version = mod.default
|
||||
})
|
||||
|
||||
it('should show simple version badge for new install', () => {
|
||||
render(<Version hasInstalled={false} toInstallVersion="1.0.0" />)
|
||||
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show upgrade version badge for existing install', () => {
|
||||
render(
|
||||
<Version
|
||||
hasInstalled={true}
|
||||
installedVersion="1.0.0"
|
||||
toInstallVersion="2.0.0"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle downgrade version display', () => {
|
||||
render(
|
||||
<Version
|
||||
hasInstalled={true}
|
||||
installedVersion="2.0.0"
|
||||
toInstallVersion="1.0.0"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('2.0.0 -> 1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useCheckInstalled from '../use-check-installed'
|
||||
|
||||
const mockPlugins = [
|
||||
{
|
||||
plugin_id: 'plugin-1',
|
||||
id: 'installed-1',
|
||||
declaration: { version: '1.0.0' },
|
||||
plugin_unique_identifier: 'org/plugin-1',
|
||||
},
|
||||
{
|
||||
plugin_id: 'plugin-2',
|
||||
id: 'installed-2',
|
||||
declaration: { version: '2.0.0' },
|
||||
plugin_unique_identifier: 'org/plugin-2',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: ({ pluginIds, enabled }: { pluginIds: string[], enabled: boolean }) => ({
|
||||
data: enabled && pluginIds.length > 0 ? { plugins: mockPlugins } : undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useCheckInstalled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return installed info when enabled and has plugin IDs', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: ['plugin-1', 'plugin-2'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.installedInfo).toBeDefined()
|
||||
expect(result.current.installedInfo?.['plugin-1']).toEqual({
|
||||
installedId: 'installed-1',
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'org/plugin-1',
|
||||
})
|
||||
expect(result.current.installedInfo?.['plugin-2']).toEqual({
|
||||
installedId: 'installed-2',
|
||||
installedVersion: '2.0.0',
|
||||
uniqueIdentifier: 'org/plugin-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return undefined installedInfo when disabled', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: ['plugin-1'],
|
||||
enabled: false,
|
||||
}))
|
||||
|
||||
expect(result.current.installedInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined installedInfo with empty plugin IDs', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: [],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.installedInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return isLoading and error states', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: ['plugin-1'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useHideLogic from '../use-hide-logic'
|
||||
|
||||
const mockFoldAnimInto = vi.fn()
|
||||
const mockClearCountDown = vi.fn()
|
||||
const mockCountDownFoldIntoAnim = vi.fn()
|
||||
|
||||
vi.mock('../use-fold-anim-into', () => ({
|
||||
default: () => ({
|
||||
modalClassName: 'test-modal-class',
|
||||
foldIntoAnim: mockFoldAnimInto,
|
||||
clearCountDown: mockClearCountDown,
|
||||
countDownFoldIntoAnim: mockCountDownFoldIntoAnim,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useHideLogic', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return initial state with modalClassName', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
expect(result.current.modalClassName).toBe('test-modal-class')
|
||||
})
|
||||
|
||||
it('should call onClose directly when not installing', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.foldAnimInto()
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
expect(mockFoldAnimInto).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call doFoldAnimInto when installing', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.handleStartToInstall()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.foldAnimInto()
|
||||
})
|
||||
|
||||
expect(mockFoldAnimInto).toHaveBeenCalled()
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set installing and start countdown on handleStartToInstall', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.handleStartToInstall()
|
||||
})
|
||||
|
||||
expect(mockCountDownFoldIntoAnim).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear countdown when setIsInstalling to false', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsInstalling(false)
|
||||
})
|
||||
|
||||
expect(mockClearCountDown).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
import { pluginInstallLimit } from '../use-install-plugin-limit'
|
||||
|
||||
const mockSystemFeatures = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
|
||||
selector({ systemFeatures: mockSystemFeatures }),
|
||||
}))
|
||||
|
||||
const basePlugin = {
|
||||
from: 'marketplace' as const,
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
}
|
||||
|
||||
describe('pluginInstallLimit', () => {
|
||||
it('should allow all plugins when scope is ALL', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny all plugins when scope is NONE', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.NONE,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, verification: { authorized_category: 'community' } }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_AND_PARTNER,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, verification: { authorized_category: 'partner' } }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny github plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, from: 'github' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should deny package plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, from: 'package' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should default to langgenius when no verification info', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
const plugin = { from: 'marketplace' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should fallback to canInstall true for unrecognized scope', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: 'unknown-scope' as InstallationScope,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePluginInstallLimit', () => {
|
||||
it('should return canInstall from pluginInstallLimit using global store', async () => {
|
||||
const { default: usePluginInstallLimit } = await import('../use-install-plugin-limit')
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
|
||||
const { result } = renderHook(() => usePluginInstallLimit(plugin as never))
|
||||
|
||||
expect(result.current.canInstall).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
|
||||
// Mock invalidation / refresh functions
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
const mockRefetchLLMModelList = vi.fn()
|
||||
const mockRefetchEmbeddingModelList = vi.fn()
|
||||
const mockRefetchRerankModelList = vi.fn()
|
||||
const mockRefreshModelProviders = vi.fn()
|
||||
const mockInvalidateAllToolProviders = vi.fn()
|
||||
const mockInvalidateAllBuiltInTools = vi.fn()
|
||||
const mockInvalidateAllDataSources = vi.fn()
|
||||
const mockInvalidateDataSourceListAuth = vi.fn()
|
||||
const mockInvalidateStrategyProviders = vi.fn()
|
||||
const mockInvalidateAllTriggerPlugins = vi.fn()
|
||||
const mockInvalidateRAGRecommendedPlugins = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: { textGeneration: 'text-generation', textEmbedding: 'text-embedding', rerank: 'rerank' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: (type: string) => {
|
||||
const map: Record<string, { mutate: ReturnType<typeof vi.fn> }> = {
|
||||
'text-generation': { mutate: mockRefetchLLMModelList },
|
||||
'text-embedding': { mutate: mockRefetchEmbeddingModelList },
|
||||
'rerank': { mutate: mockRefetchRerankModelList },
|
||||
}
|
||||
return map[type] ?? { mutate: vi.fn() }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ refreshModelProviders: mockRefreshModelProviders }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
useInvalidateAllBuiltInTools: () => mockInvalidateAllBuiltInTools,
|
||||
useInvalidateRAGRecommendedPlugins: () => mockInvalidateRAGRecommendedPlugins,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useInvalidDataSourceList: () => mockInvalidateAllDataSources,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-datasource', () => ({
|
||||
useInvalidDataSourceListAuth: () => mockInvalidateDataSourceListAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-strategy', () => ({
|
||||
useInvalidateStrategyProviders: () => mockInvalidateStrategyProviders,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useInvalidateAllTriggerPlugins: () => mockInvalidateAllTriggerPlugins,
|
||||
}))
|
||||
|
||||
const { default: useRefreshPluginList } = await import('../use-refresh-plugin-list')
|
||||
|
||||
describe('useRefreshPluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should always invalidate installed plugin list', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList()
|
||||
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh tool providers for tool category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never)
|
||||
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateRAGRecommendedPlugins).toHaveBeenCalledWith('tool')
|
||||
})
|
||||
|
||||
it('should refresh model lists for model category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.model } as never)
|
||||
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh datasource lists for datasource category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.datasource } as never)
|
||||
|
||||
expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh trigger plugins for trigger category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.trigger } as never)
|
||||
|
||||
expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh strategy providers for agent category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.agent } as never)
|
||||
|
||||
expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh all types when refreshAllType is true', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList(undefined, true)
|
||||
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateRAGRecommendedPlugins).toHaveBeenCalledWith('tool')
|
||||
expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not refresh category-specific lists when manifest is null', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList(null)
|
||||
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllToolProviders).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllDataSources).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh unrelated categories for a specific manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never)
|
||||
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllDataSources).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../types'
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } 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 InstallBundle, { InstallType } from './index'
|
||||
import GithubItem from './item/github-item'
|
||||
import LoadedItem from './item/loaded-item'
|
||||
import MarketplaceItem from './item/marketplace-item'
|
||||
import PackageItem from './item/package-item'
|
||||
import ReadyToInstall from './ready-to-install'
|
||||
import Installed from './steps/installed'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../../types'
|
||||
import InstallBundle, { InstallType } from '../index'
|
||||
import GithubItem from '../item/github-item'
|
||||
import LoadedItem from '../item/loaded-item'
|
||||
import MarketplaceItem from '../item/marketplace-item'
|
||||
import PackageItem from '../item/package-item'
|
||||
import ReadyToInstall from '../ready-to-install'
|
||||
import Installed from '../steps/installed'
|
||||
|
||||
// Factory functions for test data
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
@@ -143,19 +143,19 @@ let mockHideLogicState = {
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
vi.mock('../hooks/use-hide-logic', () => ({
|
||||
vi.mock('../../hooks/use-hide-logic', () => ({
|
||||
default: () => mockHideLogicState,
|
||||
}))
|
||||
|
||||
// Mock useGetIcon hook
|
||||
vi.mock('../base/use-get-icon', () => ({
|
||||
vi.mock('../../base/use-get-icon', () => ({
|
||||
default: () => ({
|
||||
getIconUrl: (icon: string) => icon || 'default-icon.png',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock usePluginInstallLimit hook
|
||||
vi.mock('../hooks/use-install-plugin-limit', () => ({
|
||||
vi.mock('../../hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({ canInstall: true }),
|
||||
pluginInstallLimit: () => ({ canInstall: true }),
|
||||
}))
|
||||
@@ -190,22 +190,22 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
}))
|
||||
|
||||
// Mock checkTaskStatus
|
||||
vi.mock('../base/check-task-status', () => ({
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
default: () => ({ check: vi.fn(), stop: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock useRefreshPluginList
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock useCheckInstalled
|
||||
vi.mock('../hooks/use-check-installed', () => ({
|
||||
vi.mock('../../hooks/use-check-installed', () => ({
|
||||
default: () => ({ installedInfo: {} }),
|
||||
}))
|
||||
|
||||
// Mock ReadyToInstall child component to test InstallBundle in isolation
|
||||
vi.mock('./ready-to-install', () => ({
|
||||
vi.mock('../ready-to-install', () => ({
|
||||
default: ({
|
||||
step,
|
||||
onStepChange,
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types'
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../../types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
import InstallMulti from './install-multi'
|
||||
import { PluginCategoryEnum } from '../../../../types'
|
||||
import InstallMulti from '../install-multi'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
@@ -62,12 +62,12 @@ vi.mock('@/context/global-public-context', () => ({
|
||||
}))
|
||||
|
||||
// Mock pluginInstallLimit
|
||||
vi.mock('../../hooks/use-install-plugin-limit', () => ({
|
||||
vi.mock('../../../hooks/use-install-plugin-limit', () => ({
|
||||
pluginInstallLimit: () => ({ canInstall: true }),
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../item/github-item', () => ({
|
||||
vi.mock('../../item/github-item', () => ({
|
||||
default: vi.fn().mockImplementation(({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
@@ -120,7 +120,7 @@ vi.mock('../item/github-item', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../item/marketplace-item', () => ({
|
||||
vi.mock('../../item/marketplace-item', () => ({
|
||||
default: vi.fn().mockImplementation(({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
@@ -142,7 +142,7 @@ vi.mock('../item/marketplace-item', () => ({
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('../item/package-item', () => ({
|
||||
vi.mock('../../item/package-item', () => ({
|
||||
default: vi.fn().mockImplementation(({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
@@ -163,7 +163,7 @@ vi.mock('../item/package-item', () => ({
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/loading-error', () => ({
|
||||
vi.mock('../../../base/loading-error', () => ({
|
||||
default: () => <div data-testid="loading-error">Loading Error</div>,
|
||||
}))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../types'
|
||||
import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../../types'
|
||||
import { act, 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'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../../types'
|
||||
import Install from '../install'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
// Mock checkTaskStatus
|
||||
const mockCheck = vi.fn()
|
||||
const mockStop = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
vi.mock('../../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheck,
|
||||
stop: mockStop,
|
||||
@@ -51,7 +51,7 @@ vi.mock('../../base/check-task-status', () => ({
|
||||
|
||||
// Mock useRefreshPluginList
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
|
||||
vi.mock('../../../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: mockRefreshPluginList,
|
||||
}),
|
||||
@@ -69,7 +69,7 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
}))
|
||||
|
||||
// Mock InstallMulti component with forwardRef support
|
||||
vi.mock('./install-multi', async () => {
|
||||
vi.mock('../install-multi', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
const createPlugin = (index: number) => ({
|
||||
@@ -838,7 +838,7 @@ describe('Install Component', () => {
|
||||
// ==================== Memoization Test ====================
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized', async () => {
|
||||
const InstallModule = await import('./install')
|
||||
const InstallModule = await import('../install')
|
||||
// memo returns an object with $$typeof
|
||||
expect(typeof InstallModule.default).toBe('object')
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../types'
|
||||
import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils'
|
||||
import InstallFromGitHub from './index'
|
||||
import { PluginCategoryEnum } from '../../../types'
|
||||
import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../../utils'
|
||||
import InstallFromGitHub from '../index'
|
||||
|
||||
// Factory functions for test data (defined before mocks that use them)
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
@@ -69,12 +69,12 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
||||
}))
|
||||
|
||||
const mockFetchReleases = vi.fn()
|
||||
vi.mock('../hooks', () => ({
|
||||
vi.mock('../../hooks', () => ({
|
||||
useGitHubReleases: () => ({ fetchReleases: mockFetchReleases }),
|
||||
}))
|
||||
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
@@ -84,12 +84,12 @@ let mockHideLogicState = {
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
vi.mock('../hooks/use-hide-logic', () => ({
|
||||
vi.mock('../../hooks/use-hide-logic', () => ({
|
||||
default: () => mockHideLogicState,
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./steps/setURL', () => ({
|
||||
vi.mock('../steps/setURL', () => ({
|
||||
default: ({ repoUrl, onChange, onNext, onCancel }: {
|
||||
repoUrl: string
|
||||
onChange: (value: string) => void
|
||||
@@ -108,7 +108,7 @@ vi.mock('./steps/setURL', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./steps/selectPackage', () => ({
|
||||
vi.mock('../steps/selectPackage', () => ({
|
||||
default: ({
|
||||
repoUrl,
|
||||
selectedVersion,
|
||||
@@ -170,7 +170,7 @@ vi.mock('./steps/selectPackage', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./steps/loaded', () => ({
|
||||
vi.mock('../steps/loaded', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
@@ -208,7 +208,7 @@ vi.mock('./steps/loaded', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../base/installed', () => ({
|
||||
vi.mock('../../base/installed', () => ({
|
||||
default: ({ payload, isFailed, errMsg, onCancel }: {
|
||||
payload: PluginDeclaration | null
|
||||
isFailed: boolean
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
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'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../../types'
|
||||
import Loaded from '../loaded'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseCheckInstalled = vi.fn()
|
||||
@@ -23,12 +23,12 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
}))
|
||||
|
||||
const mockCheck = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
vi.mock('../../../base/check-task-status', () => ({
|
||||
default: () => ({ check: mockCheck }),
|
||||
}))
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('../../../card', () => ({
|
||||
vi.mock('../../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => (
|
||||
<div data-testid="plugin-card">
|
||||
<span data-testid="card-name">{payload.name}</span>
|
||||
@@ -38,7 +38,7 @@ vi.mock('../../../card', () => ({
|
||||
}))
|
||||
|
||||
// Mock Version component
|
||||
vi.mock('../../base/version', () => ({
|
||||
vi.mock('../../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
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'
|
||||
import { PluginCategoryEnum } from '../../../../types'
|
||||
import SelectPackage from '../selectPackage'
|
||||
|
||||
// Mock the useGitHubUpload hook
|
||||
const mockHandleUpload = vi.fn()
|
||||
vi.mock('../../hooks', () => ({
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useGitHubUpload: () => ({ handleUpload: mockHandleUpload }),
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SetURL from './setURL'
|
||||
import SetURL from '../setURL'
|
||||
|
||||
describe('SetURL', () => {
|
||||
const defaultProps = {
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Dependency, PluginDeclaration } from '../../types'
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../types'
|
||||
import InstallFromLocalPackage from './index'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../../types'
|
||||
import InstallFromLocalPackage from '../index'
|
||||
|
||||
// Factory functions for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
@@ -64,7 +64,7 @@ let mockHideLogicState = {
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
vi.mock('../hooks/use-hide-logic', () => ({
|
||||
vi.mock('../../hooks/use-hide-logic', () => ({
|
||||
default: () => mockHideLogicState,
|
||||
}))
|
||||
|
||||
@@ -73,7 +73,7 @@ let uploadingOnPackageUploaded: ((result: { uniqueIdentifier: string, manifest:
|
||||
let uploadingOnBundleUploaded: ((result: Dependency[]) => void) | null = null
|
||||
let _uploadingOnFailed: ((errorMsg: string) => void) | null = null
|
||||
|
||||
vi.mock('./steps/uploading', () => ({
|
||||
vi.mock('../steps/uploading', () => ({
|
||||
default: ({
|
||||
isBundle,
|
||||
file,
|
||||
@@ -127,7 +127,7 @@ let _packageStepChangeCallback: ((step: InstallStep) => void) | null = null
|
||||
let _packageSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null
|
||||
let _packageOnErrorCallback: ((errorMsg: string) => void) | null = null
|
||||
|
||||
vi.mock('./ready-to-install', () => ({
|
||||
vi.mock('../ready-to-install', () => ({
|
||||
default: ({
|
||||
step,
|
||||
onStepChange,
|
||||
@@ -192,7 +192,7 @@ vi.mock('./ready-to-install', () => ({
|
||||
let _bundleStepChangeCallback: ((step: InstallStep) => void) | null = null
|
||||
let _bundleSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null
|
||||
|
||||
vi.mock('../install-bundle/ready-to-install', () => ({
|
||||
vi.mock('../../install-bundle/ready-to-install', () => ({
|
||||
default: ({
|
||||
step,
|
||||
onStepChange,
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PluginDeclaration } from '../../types'
|
||||
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'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../../types'
|
||||
import ReadyToInstall from '../ready-to-install'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
@@ -29,7 +29,7 @@ const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginD
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: mockRefreshPluginList,
|
||||
}),
|
||||
@@ -41,7 +41,7 @@ let _installOnFailed: ((message?: string) => void) | null = null
|
||||
let _installOnCancel: (() => void) | null = null
|
||||
let _installOnStartToInstall: (() => void) | null = null
|
||||
|
||||
vi.mock('./steps/install', () => ({
|
||||
vi.mock('../steps/install', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
@@ -87,7 +87,7 @@ vi.mock('./steps/install', () => ({
|
||||
}))
|
||||
|
||||
// Mock Installed component
|
||||
vi.mock('../base/installed', () => ({
|
||||
vi.mock('../../base/installed', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isFailed,
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PluginDeclaration } from '../../../types'
|
||||
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'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../../types'
|
||||
import Install from '../install'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
@@ -50,7 +50,7 @@ vi.mock('@/service/plugins', () => ({
|
||||
|
||||
const mockCheck = vi.fn()
|
||||
const mockStop = vi.fn()
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
vi.mock('../../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheck,
|
||||
stop: mockStop,
|
||||
@@ -64,22 +64,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...actual,
|
||||
...createReactI18nextMock(),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
|
||||
<span data-testid="trans">
|
||||
{i18nKey}
|
||||
{components?.trustSource}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
vi.mock('../../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: {
|
||||
payload: Record<string, unknown>
|
||||
titleLeft?: React.ReactNode
|
||||
@@ -91,7 +76,7 @@ vi.mock('../../../card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/version', () => ({
|
||||
vi.mock('../../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
@@ -105,7 +90,7 @@ vi.mock('../../base/version', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
vi.mock('../../../utils', () => ({
|
||||
pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({
|
||||
name: manifest.name,
|
||||
author: manifest.author,
|
||||
@@ -148,7 +133,7 @@ describe('Install', () => {
|
||||
it('should render trust source message', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('trans')).toBeInTheDocument()
|
||||
expect(screen.getByText('installModal.fromTrustSource')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin card', () => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
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'
|
||||
import { PluginCategoryEnum } from '../../../../types'
|
||||
import Uploading from '../uploading'
|
||||
|
||||
// Factory function for test data
|
||||
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
@@ -48,7 +48,7 @@ vi.mock('@/service/plugins', () => ({
|
||||
uploadFile: (...args: unknown[]) => mockUploadFile(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
vi.mock('../../../../card', () => ({
|
||||
default: ({ payload, isLoading, loadingFileName }: {
|
||||
payload: { name: string }
|
||||
isLoading?: boolean
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Dependency, Plugin, PluginManifestInMarket } from '../../types'
|
||||
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'
|
||||
import { InstallStep, PluginCategoryEnum } from '../../../types'
|
||||
import InstallFromMarketplace from '../index'
|
||||
|
||||
// Factory functions for test data
|
||||
// Use type casting to avoid strict locale requirements in tests
|
||||
@@ -69,7 +69,7 @@ const createMockDependencies = (): Dependency[] => [
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
vi.mock('../hooks/use-refresh-plugin-list', () => ({
|
||||
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
@@ -79,12 +79,12 @@ let mockHideLogicState = {
|
||||
setIsInstalling: vi.fn(),
|
||||
handleStartToInstall: vi.fn(),
|
||||
}
|
||||
vi.mock('../hooks/use-hide-logic', () => ({
|
||||
vi.mock('../../hooks/use-hide-logic', () => ({
|
||||
default: () => mockHideLogicState,
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./steps/install', () => ({
|
||||
vi.mock('../steps/install', () => ({
|
||||
default: ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
@@ -113,7 +113,7 @@ vi.mock('./steps/install', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../install-bundle/ready-to-install', () => ({
|
||||
vi.mock('../../install-bundle/ready-to-install', () => ({
|
||||
default: ({
|
||||
step,
|
||||
onStepChange,
|
||||
@@ -145,7 +145,7 @@ vi.mock('../install-bundle/ready-to-install', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../base/installed', () => ({
|
||||
vi.mock('../../base/installed', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
isMarketPayload,
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Plugin, PluginManifestInMarket } from '../../../types'
|
||||
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'
|
||||
import { PluginCategoryEnum, TaskStatus } from '../../../../types'
|
||||
import Install from '../install'
|
||||
|
||||
// Factory functions for test data
|
||||
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
|
||||
@@ -64,7 +64,7 @@ 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 }) => ({
|
||||
default: ({ pluginIds: _pluginIds }: { pluginIds: string[], enabled: boolean }) => ({
|
||||
installedInfo: mockInstalledInfo,
|
||||
isLoading: mockIsLoading,
|
||||
error: null,
|
||||
@@ -88,7 +88,7 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
}))
|
||||
|
||||
// Mock checkTaskStatus
|
||||
vi.mock('../../base/check-task-status', () => ({
|
||||
vi.mock('../../../base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheckTaskStatus,
|
||||
stop: mockStopTaskStatus,
|
||||
@@ -103,20 +103,20 @@ vi.mock('@/context/app-context', () => ({
|
||||
}))
|
||||
|
||||
// Mock useInstallPluginLimit
|
||||
vi.mock('../../hooks/use-install-plugin-limit', () => ({
|
||||
vi.mock('../../../hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({ canInstall: mockCanInstall }),
|
||||
}))
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft, className, limitedInstall }: {
|
||||
payload: any
|
||||
vi.mock('../../../../card', () => ({
|
||||
default: ({ payload, titleLeft, className: _className, limitedInstall }: {
|
||||
payload: Record<string, unknown>
|
||||
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-payload-name">{String(payload?.name ?? '')}</span>
|
||||
<span data-testid="card-limited-install">{limitedInstall ? 'true' : 'false'}</span>
|
||||
{!!titleLeft && <div data-testid="card-title-left">{titleLeft}</div>}
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ vi.mock('../../../card', () => ({
|
||||
}))
|
||||
|
||||
// Mock Version component
|
||||
vi.mock('../../base/version', () => ({
|
||||
vi.mock('../../../base/version', () => ({
|
||||
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
|
||||
hasInstalled: boolean
|
||||
installedVersion?: string
|
||||
@@ -139,7 +139,7 @@ vi.mock('../../base/version', () => ({
|
||||
}))
|
||||
|
||||
// Mock utils
|
||||
vi.mock('../../utils', () => ({
|
||||
vi.mock('../../../utils', () => ({
|
||||
pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({
|
||||
name: payload.name,
|
||||
icon: payload.icon,
|
||||
@@ -255,7 +255,7 @@ describe('Install Component (steps/install.tsx)', () => {
|
||||
})
|
||||
|
||||
it('should fallback to latest_version when version is undefined', () => {
|
||||
const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' })
|
||||
const manifest = createMockManifest({ version: undefined as unknown as string, latest_version: '3.0.0' })
|
||||
render(<Install {...defaultProps} payload={manifest} />)
|
||||
|
||||
expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0')
|
||||
@@ -701,7 +701,7 @@ describe('Install Component (steps/install.tsx)', () => {
|
||||
})
|
||||
|
||||
it('should handle null current_version in langGeniusVersionInfo', () => {
|
||||
mockLangGeniusVersionInfo = { current_version: null as any }
|
||||
mockLangGeniusVersionInfo = { current_version: null as unknown as string }
|
||||
mockPluginDeclaration = {
|
||||
manifest: { meta: { minimum_dify_version: '1.0.0' } },
|
||||
}
|
||||
601
web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx
Normal file
601
web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ================================
|
||||
// Mock External Dependencies
|
||||
// ================================
|
||||
|
||||
vi.mock('@/i18n-config/i18next-config', () => ({
|
||||
default: {
|
||||
getFixedT: () => (key: string) => key,
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSetUrlFilters = vi.fn()
|
||||
vi.mock('@/hooks/use-query-params', () => ({
|
||||
useMarketplaceFilters: () => [
|
||||
{ q: '', tags: [], category: '' },
|
||||
mockSetUrlFilters,
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { plugins: [] },
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockHasNextPage = false
|
||||
let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined
|
||||
let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null
|
||||
let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null
|
||||
let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => {
|
||||
capturedQueryFn = queryFn
|
||||
if (queryFn) {
|
||||
const controller = new AbortController()
|
||||
queryFn({ signal: controller.signal }).catch(() => {})
|
||||
}
|
||||
return {
|
||||
data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
isSuccess: enabled,
|
||||
}
|
||||
}),
|
||||
useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: {
|
||||
queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>
|
||||
getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined
|
||||
enabled: boolean
|
||||
}) => {
|
||||
capturedInfiniteQueryFn = queryFn
|
||||
capturedGetNextPageParam = getNextPageParam
|
||||
if (queryFn) {
|
||||
const controller = new AbortController()
|
||||
queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {})
|
||||
}
|
||||
if (getNextPageParam) {
|
||||
getNextPageParam({ page: 1, page_size: 40, total: 100 })
|
||||
getNextPageParam({ page: 3, page_size: 40, total: 100 })
|
||||
}
|
||||
return {
|
||||
data: mockInfiniteQueryData,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: mockHasNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
}
|
||||
}),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
removeQueries: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
|
||||
run: fn,
|
||||
cancel: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockPostMarketplaceShouldFail = false
|
||||
const mockPostMarketplaceResponse = {
|
||||
data: {
|
||||
plugins: [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
|
||||
],
|
||||
bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>,
|
||||
total: 2,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
postMarketplace: vi.fn(() => {
|
||||
if (mockPostMarketplaceShouldFail)
|
||||
return Promise.reject(new Error('Mock API error'))
|
||||
return Promise.resolve(mockPostMarketplaceResponse)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
APP_VERSION: '1.0.0',
|
||||
IS_MARKETPLACE: false,
|
||||
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: vi.fn(async () => ({
|
||||
data: {
|
||||
collections: [
|
||||
{
|
||||
name: 'collection-1',
|
||||
label: { 'en-US': 'Collection 1' },
|
||||
description: { 'en-US': 'Desc' },
|
||||
rule: '',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
searchable: true,
|
||||
search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
collectionPlugins: vi.fn(async () => ({
|
||||
data: {
|
||||
plugins: [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
],
|
||||
},
|
||||
})),
|
||||
searchAdvanced: vi.fn(async () => ({
|
||||
data: {
|
||||
plugins: [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// useMarketplaceCollectionsAndPlugins Tests
|
||||
// ================================
|
||||
describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return initial state correctly', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollections).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollections function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(typeof result.current.setMarketplaceCollections).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
|
||||
})
|
||||
|
||||
it('should return marketplaceCollections from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(result.current.marketplaceCollections).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplacePluginsByCollectionId Tests
|
||||
// ================================
|
||||
describe('useMarketplacePluginsByCollectionId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return initial state when collectionId is undefined', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined))
|
||||
expect(result.current.plugins).toEqual([])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
})
|
||||
|
||||
it('should return isLoading false when collectionId is provided and query completes', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'))
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept query parameter', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
|
||||
const { result } = renderHook(() =>
|
||||
useMarketplacePluginsByCollectionId('test-collection', {
|
||||
category: 'tool',
|
||||
type: 'plugin',
|
||||
}))
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return plugins property from hook', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'))
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplacePlugins Tests
|
||||
// ================================
|
||||
describe('useMarketplacePlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInfiniteQueryData = undefined
|
||||
})
|
||||
|
||||
it('should return initial state correctly', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(result.current.plugins).toBeUndefined()
|
||||
expect(result.current.total).toBeUndefined()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isFetchingNextPage).toBe(false)
|
||||
expect(result.current.hasNextPage).toBe(false)
|
||||
expect(result.current.page).toBe(0)
|
||||
})
|
||||
|
||||
it('should provide queryPlugins function', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.queryPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide queryPluginsWithDebounced function', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.queryPluginsWithDebounced).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide cancelQueryPluginsWithDebounced function', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide resetPlugins function', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.resetPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide fetchNextPage function', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.fetchNextPage).toBe('function')
|
||||
})
|
||||
|
||||
it('should handle queryPlugins call without errors', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
sort_by: 'install_count',
|
||||
sort_order: 'DESC',
|
||||
category: 'tool',
|
||||
page_size: 20,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle queryPlugins with bundle type', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
type: 'bundle',
|
||||
page_size: 40,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle resetPlugins call', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.resetPlugins()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle queryPluginsWithDebounced call', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPluginsWithDebounced({
|
||||
query: 'debounced search',
|
||||
category: 'all',
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle cancelQueryPluginsWithDebounced call', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.cancelQueryPluginsWithDebounced()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should return correct page number', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(result.current.page).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle queryPlugins with tags', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
tags: ['search', 'image'],
|
||||
exclude: ['excluded-plugin'],
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Hooks queryFn Coverage Tests
|
||||
// ================================
|
||||
describe('Hooks queryFn Coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInfiniteQueryData = undefined
|
||||
mockPostMarketplaceShouldFail = false
|
||||
capturedInfiniteQueryFn = null
|
||||
capturedQueryFn = null
|
||||
})
|
||||
|
||||
it('should cover queryFn with pages data', async () => {
|
||||
mockInfiniteQueryData = {
|
||||
pages: [
|
||||
{ plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 },
|
||||
],
|
||||
}
|
||||
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
category: 'tool',
|
||||
})
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should expose page and total from infinite query data', async () => {
|
||||
mockInfiniteQueryData = {
|
||||
pages: [
|
||||
{ plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 },
|
||||
{ plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 },
|
||||
],
|
||||
}
|
||||
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({ query: 'search' })
|
||||
expect(result.current.page).toBe(2)
|
||||
})
|
||||
|
||||
it('should return undefined total when no query is set', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(result.current.total).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should directly test queryFn execution', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({
|
||||
query: 'direct test',
|
||||
category: 'tool',
|
||||
sort_by: 'install_count',
|
||||
sort_order: 'DESC',
|
||||
page_size: 40,
|
||||
})
|
||||
|
||||
if (capturedInfiniteQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should test queryFn with bundle type', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({
|
||||
type: 'bundle',
|
||||
query: 'bundle test',
|
||||
})
|
||||
|
||||
if (capturedInfiniteQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should test queryFn error handling', async () => {
|
||||
mockPostMarketplaceShouldFail = true
|
||||
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({ query: 'test that will fail' })
|
||||
|
||||
if (capturedInfiniteQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
expect(response).toHaveProperty('plugins')
|
||||
}
|
||||
|
||||
mockPostMarketplaceShouldFail = false
|
||||
})
|
||||
|
||||
it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
result.current.queryMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
})
|
||||
|
||||
if (capturedQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedQueryFn({ signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should test getNextPageParam directly', async () => {
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
renderHook(() => useMarketplacePlugins())
|
||||
|
||||
if (capturedGetNextPageParam) {
|
||||
const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 })
|
||||
expect(nextPage).toBe(2)
|
||||
|
||||
const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 })
|
||||
expect(noMorePages).toBeUndefined()
|
||||
|
||||
const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 })
|
||||
expect(atBoundary).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplaceContainerScroll Tests
|
||||
// ================================
|
||||
describe('useMarketplaceContainerScroll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should attach scroll event listener to container', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
|
||||
const { useMarketplaceContainerScroll } = await import('../hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback)
|
||||
return null
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
it('should call callback when scrolled to bottom', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'scroll-test-container-hooks'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true })
|
||||
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
|
||||
|
||||
const { useMarketplaceContainerScroll } = await import('../hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks')
|
||||
return null
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
const scrollEvent = new Event('scroll')
|
||||
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
|
||||
mockContainer.dispatchEvent(scrollEvent)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalled()
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
it('should not call callback when scrollTop is 0', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'scroll-test-container-hooks-2'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true })
|
||||
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
|
||||
|
||||
const { useMarketplaceContainerScroll } = await import('../hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks-2')
|
||||
return null
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
const scrollEvent = new Event('scroll')
|
||||
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
|
||||
mockContainer.dispatchEvent(scrollEvent)
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
it('should remove event listener on unmount', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'scroll-unmount-container-hooks'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
|
||||
const { useMarketplaceContainerScroll } = await import('../hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container-hooks')
|
||||
return null
|
||||
}
|
||||
|
||||
const { unmount } = render(<TestComponent />)
|
||||
unmount()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
// The Marketplace index component is an async Server Component
|
||||
// that cannot be unit tested in jsdom. It is covered by integration tests.
|
||||
//
|
||||
// All sub-module tests have been moved to dedicated spec files:
|
||||
// - constants.spec.ts (DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD, PLUGIN_TYPE_SEARCH_MAP)
|
||||
// - utils.spec.ts (getPluginIconInMarketplace, getFormattedPlugin, getPluginLinkInMarketplace, etc.)
|
||||
// - hooks.spec.tsx (useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceContainerScroll)
|
||||
|
||||
describe('Marketplace index', () => {
|
||||
it('should be covered by dedicated sub-module specs', () => {
|
||||
// Placeholder to document the split
|
||||
})
|
||||
})
|
||||
317
web/app/components/plugins/marketplace/__tests__/utils.spec.ts
Normal file
317
web/app/components/plugins/marketplace/__tests__/utils.spec.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
APP_VERSION: '1.0.0',
|
||||
IS_MARKETPLACE: false,
|
||||
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
|
||||
}))
|
||||
|
||||
// Mock var utils
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
// Mock marketplace client
|
||||
const mockCollectionPlugins = vi.fn()
|
||||
const mockCollections = vi.fn()
|
||||
const mockSearchAdvanced = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Factory for creating mock plugins
|
||||
const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'test-plugin',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-org/test-plugin:1.0.0',
|
||||
icon: '/icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Test Plugin' },
|
||||
brief: { 'en-US': 'Test plugin brief' },
|
||||
description: { 'en-US': 'Test plugin description' },
|
||||
introduction: 'Test plugin introduction',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 1000,
|
||||
endpoint: { settings: [] },
|
||||
tags: [{ name: 'search' }],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('getPluginIconInMarketplace', () => {
|
||||
it('should return correct icon URL for regular plugin', async () => {
|
||||
const { getPluginIconInMarketplace } = await import('../utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const iconUrl = getPluginIconInMarketplace(plugin)
|
||||
expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon')
|
||||
})
|
||||
|
||||
it('should return correct icon URL for bundle', async () => {
|
||||
const { getPluginIconInMarketplace } = await import('../utils')
|
||||
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
|
||||
const iconUrl = getPluginIconInMarketplace(bundle)
|
||||
expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFormattedPlugin', () => {
|
||||
it('should format plugin with icon URL', async () => {
|
||||
const { getFormattedPlugin } = await import('../utils')
|
||||
const rawPlugin = {
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'test-plugin',
|
||||
tags: [{ name: 'search' }],
|
||||
} as unknown as Plugin
|
||||
|
||||
const formatted = getFormattedPlugin(rawPlugin)
|
||||
expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon')
|
||||
})
|
||||
|
||||
it('should format bundle with additional properties', async () => {
|
||||
const { getFormattedPlugin } = await import('../utils')
|
||||
const rawBundle = {
|
||||
type: 'bundle',
|
||||
org: 'test-org',
|
||||
name: 'test-bundle',
|
||||
description: 'Bundle description',
|
||||
labels: { 'en-US': 'Test Bundle' },
|
||||
} as unknown as Plugin
|
||||
|
||||
const formatted = getFormattedPlugin(rawBundle)
|
||||
expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon')
|
||||
expect(formatted.brief).toBe('Bundle description')
|
||||
expect(formatted.label).toEqual({ 'en-US': 'Test Bundle' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPluginLinkInMarketplace', () => {
|
||||
it('should return correct link for regular plugin', async () => {
|
||||
const { getPluginLinkInMarketplace } = await import('../utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginLinkInMarketplace(plugin)
|
||||
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct link for bundle', async () => {
|
||||
const { getPluginLinkInMarketplace } = await import('../utils')
|
||||
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
|
||||
const link = getPluginLinkInMarketplace(bundle)
|
||||
expect(link).toBe('https://marketplace.dify.ai/bundles/test-org/test-bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPluginDetailLinkInMarketplace', () => {
|
||||
it('should return correct detail link for regular plugin', async () => {
|
||||
const { getPluginDetailLinkInMarketplace } = await import('../utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginDetailLinkInMarketplace(plugin)
|
||||
expect(link).toBe('/plugins/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct detail link for bundle', async () => {
|
||||
const { getPluginDetailLinkInMarketplace } = await import('../utils')
|
||||
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
|
||||
const link = getPluginDetailLinkInMarketplace(bundle)
|
||||
expect(link).toBe('/bundles/test-org/test-bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListCondition', () => {
|
||||
it('should return category condition for tool', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
})
|
||||
|
||||
it('should return category condition for model', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
})
|
||||
|
||||
it('should return category condition for agent', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
})
|
||||
|
||||
it('should return category condition for datasource', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
})
|
||||
|
||||
it('should return category condition for trigger', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
})
|
||||
|
||||
it('should return endpoint category for extension', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
})
|
||||
|
||||
it('should return type condition for bundle', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
|
||||
})
|
||||
|
||||
it('should return empty string for all', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition('all')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown type', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition('unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListFilterType', () => {
|
||||
it('should return undefined for all', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('../utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return bundle for bundle', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('../utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
})
|
||||
|
||||
it('should return plugin for other categories', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('../utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplacePluginsByCollectionId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should fetch plugins by collection id successfully', async () => {
|
||||
const mockPlugins = [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
|
||||
]
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection', {
|
||||
category: 'tool',
|
||||
exclude: ['excluded-plugin'],
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalled()
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty array', async () => {
|
||||
mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should pass abort signal when provided', async () => {
|
||||
const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal })
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalled()
|
||||
const call = mockCollectionPlugins.mock.calls[0]
|
||||
expect(call[1]).toMatchObject({ signal: controller.signal })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should fetch collections and plugins successfully', async () => {
|
||||
const mockCollectionData = [
|
||||
{ name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
|
||||
]
|
||||
const mockPluginData = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
|
||||
|
||||
mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } })
|
||||
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
expect(result.marketplaceCollections).toBeDefined()
|
||||
expect(result.marketplaceCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty data', async () => {
|
||||
mockCollections.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins()
|
||||
|
||||
expect(result.marketplaceCollections).toEqual([])
|
||||
expect(result.marketplaceCollectionPluginsMap).toEqual({})
|
||||
})
|
||||
|
||||
it('should append condition and type to URL when provided', async () => {
|
||||
mockCollections.mockResolvedValueOnce({ data: { collections: [] } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
await getMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
type: 'bundle',
|
||||
})
|
||||
|
||||
expect(mockCollections).toHaveBeenCalled()
|
||||
const call = mockCollections.mock.calls[0]
|
||||
expect(call[0]).toMatchObject({ query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCollectionsParams', () => {
|
||||
it('should return empty object for all category', async () => {
|
||||
const { getCollectionsParams } = await import('../utils')
|
||||
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({})
|
||||
})
|
||||
|
||||
it('should return category, condition, and type for tool category', async () => {
|
||||
const { getCollectionsParams } = await import('../utils')
|
||||
const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool)
|
||||
expect(result).toEqual({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,75 +1,31 @@
|
||||
import type { SearchTab } from './search-params'
|
||||
import type { PluginsSort, SearchParamsFromCollection } from './types'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
const marketplacePluginSortAtom = atom<PluginsSort>(DEFAULT_PLUGIN_SORT)
|
||||
export function useMarketplacePluginSort() {
|
||||
return useAtom(marketplacePluginSortAtom)
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
export function useMarketplaceSort() {
|
||||
return useAtom(marketplaceSortAtom)
|
||||
}
|
||||
export function useMarketplacePluginSortValue() {
|
||||
return useAtomValue(marketplacePluginSortAtom)
|
||||
export function useMarketplaceSortValue() {
|
||||
return useAtomValue(marketplaceSortAtom)
|
||||
}
|
||||
export function useSetMarketplacePluginSort() {
|
||||
return useSetAtom(marketplacePluginSortAtom)
|
||||
export function useSetMarketplaceSort() {
|
||||
return useSetAtom(marketplaceSortAtom)
|
||||
}
|
||||
|
||||
const marketplaceTemplateSortAtom = atom<PluginsSort>(DEFAULT_TEMPLATE_SORT)
|
||||
export function useMarketplaceTemplateSort() {
|
||||
return useAtom(marketplaceTemplateSortAtom)
|
||||
}
|
||||
export function useMarketplaceTemplateSortValue() {
|
||||
return useAtomValue(marketplaceTemplateSortAtom)
|
||||
}
|
||||
export function useSetMarketplaceTemplateSort() {
|
||||
return useSetAtom(marketplaceTemplateSortAtom)
|
||||
}
|
||||
|
||||
export function useSearchText() {
|
||||
export function useSearchPluginText() {
|
||||
return useQueryState('q', marketplaceSearchParamsParsers.q)
|
||||
}
|
||||
export function useActivePluginCategory() {
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
return [getValidatedPluginCategory(category), setCategory] as const
|
||||
}
|
||||
|
||||
export function useActiveTemplateCategory() {
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
return [getValidatedTemplateCategory(category), setCategory] as const
|
||||
export function useActivePluginType() {
|
||||
return useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
}
|
||||
export function useFilterPluginTags() {
|
||||
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
|
||||
}
|
||||
|
||||
export function useSearchTab() {
|
||||
return useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
|
||||
}
|
||||
|
||||
export function useCreationType() {
|
||||
return useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
|
||||
}
|
||||
|
||||
// Search-page-specific filter hooks (separate from list-page category/tags)
|
||||
export function useSearchFilterCategories() {
|
||||
return useQueryState('searchCategories', marketplaceSearchParamsParsers.searchCategories)
|
||||
}
|
||||
|
||||
export function useSearchFilterLanguages() {
|
||||
return useQueryState('searchLanguages', marketplaceSearchParamsParsers.searchLanguages)
|
||||
}
|
||||
|
||||
export function useSearchFilterType() {
|
||||
const [type, setType] = useQueryState('searchType', marketplaceSearchParamsParsers.searchType)
|
||||
return [getValidatedPluginCategory(type), setType] as const
|
||||
}
|
||||
|
||||
export function useSearchFilterTags() {
|
||||
return useQueryState('searchTags', marketplaceSearchParamsParsers.searchTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all categories have collections, so we need to
|
||||
* force the search mode for those categories.
|
||||
@@ -77,72 +33,30 @@ export function useSearchFilterTags() {
|
||||
export const searchModeAtom = atom<true | null>(null)
|
||||
|
||||
export function useMarketplaceSearchMode() {
|
||||
const [creationType] = useCreationType()
|
||||
const [searchText] = useSearchText()
|
||||
const [searchTab] = useSearchTab()
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
const [activeTemplateCategory] = useActiveTemplateCategory()
|
||||
const isPluginsView = creationType === CREATION_TYPE.plugins
|
||||
const [activePluginType] = useActivePluginType()
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode = searchTab || searchText
|
||||
|| (isPluginsView && filterPluginTags.length > 0)
|
||||
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|
||||
|| (!isPluginsView && activeTemplateCategory !== CATEGORY_ALL)
|
||||
const isSearchMode = !!searchPluginText
|
||||
|| filterPluginTags.length > 0
|
||||
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active sort state based on the current creationType.
|
||||
* Plugins use `marketplacePluginSortAtom`, templates use `marketplaceTemplateSortAtom`.
|
||||
*/
|
||||
export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
|
||||
const [creationType] = useCreationType()
|
||||
const [pluginSort, setPluginSort] = useAtom(marketplacePluginSortAtom)
|
||||
const [templateSort, setTemplateSort] = useAtom(marketplaceTemplateSortAtom)
|
||||
const isTemplates = creationType === CREATION_TYPE.templates
|
||||
|
||||
const sort = isTemplates ? templateSort : pluginSort
|
||||
const setSort = useMemo(
|
||||
() => isTemplates ? setTemplateSort : setPluginSort,
|
||||
[isTemplates, setTemplateSort, setPluginSort],
|
||||
)
|
||||
return [sort, setSort]
|
||||
}
|
||||
|
||||
export function useActiveSortValue(): PluginsSort {
|
||||
const [creationType] = useCreationType()
|
||||
const pluginSort = useAtomValue(marketplacePluginSortAtom)
|
||||
const templateSort = useAtomValue(marketplaceTemplateSortAtom)
|
||||
return creationType === CREATION_TYPE.templates ? templateSort : pluginSort
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [, setQ] = useSearchText()
|
||||
const [, setSearchTab] = useSearchTab()
|
||||
const setPluginSort = useSetAtom(marketplacePluginSortAtom)
|
||||
const setTemplateSort = useSetAtom(marketplaceTemplateSortAtom)
|
||||
const [,setQ] = useSearchPluginText()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
|
||||
return useCallback((searchParams?: SearchParamsFromCollection) => {
|
||||
if (!searchParams)
|
||||
return
|
||||
setQ(searchParams?.query || '')
|
||||
if (searchTab === 'templates') {
|
||||
setTemplateSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
})
|
||||
}
|
||||
else {
|
||||
setPluginSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
})
|
||||
}
|
||||
setSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
setSearchMode(true)
|
||||
if (searchTab)
|
||||
setSearchTab(searchTab)
|
||||
}, [setQ, setSearchTab, setPluginSort, setTemplateSort, setSearchMode])
|
||||
}, [setQ, setSort, setSearchMode])
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType, ActiveTemplateCategory } from '../constants'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP, TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
|
||||
/**
|
||||
* Returns a getter that translates a plugin category value to its display text.
|
||||
* Pass `allAsAllTypes = true` to use "All types" instead of "All" for the `all` category
|
||||
* (e.g. hero variant in category switch).
|
||||
*/
|
||||
export function usePluginCategoryText() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (category: ActivePluginType, allAsAllTypes = false): string => {
|
||||
switch (category) {
|
||||
case PLUGIN_TYPE_SEARCH_MAP.model:
|
||||
return t('category.models', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.tool:
|
||||
return t('category.tools', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.datasource:
|
||||
return t('category.datasources', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.trigger:
|
||||
return t('category.triggers', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.agent:
|
||||
return t('category.agents', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.extension:
|
||||
return t('category.extensions', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.bundle:
|
||||
return t('category.bundles', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.all:
|
||||
default:
|
||||
return allAsAllTypes
|
||||
? t('category.allTypes', { ns: 'plugin' })
|
||||
: t('category.all', { ns: 'plugin' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a getter that translates a template category value to its display text.
|
||||
*/
|
||||
export function useTemplateCategoryText() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (category: ActiveTemplateCategory): string => {
|
||||
switch (category) {
|
||||
case TEMPLATE_CATEGORY_MAP.marketing:
|
||||
return t('marketplace.templateCategory.marketing', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.sales:
|
||||
return t('marketplace.templateCategory.sales', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.support:
|
||||
return t('marketplace.templateCategory.support', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.operations:
|
||||
return t('marketplace.templateCategory.operations', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.it:
|
||||
return t('marketplace.templateCategory.it', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.knowledge:
|
||||
return t('marketplace.templateCategory.knowledge', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.design:
|
||||
return t('marketplace.templateCategory.design', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.all:
|
||||
default:
|
||||
return t('marketplace.templateCategory.all', { ns: 'plugin' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type CategoryOption = {
|
||||
value: string
|
||||
text: string
|
||||
icon: React.ReactNode | null
|
||||
}
|
||||
|
||||
type CategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
options: CategoryOption[]
|
||||
activeValue: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const CommonCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
}: CategorySwitchProps) => {
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const getItemClassName = (isActive: boolean) => {
|
||||
if (isHeroVariant) {
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
|
||||
isActive
|
||||
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center space-x-2',
|
||||
!isHeroVariant && 'justify-center bg-background-body py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={getItemClassName(activeValue === option.value)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import HeroTagsTrigger from './hero-tags-trigger'
|
||||
|
||||
type HeroTagsFilterProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsFilter = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: HeroTagsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags()
|
||||
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const handleCheck = (id: string) => {
|
||||
if (tags.includes(id))
|
||||
onTagsChange(tags.filter((tag: string) => tag !== id))
|
||||
else
|
||||
onTagsChange([...tags, id])
|
||||
}
|
||||
const selectedTagsLength = tags.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<HeroTagsTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.name}
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleCheck(option.name)}
|
||||
>
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={tags.includes(option.name)}
|
||||
/>
|
||||
<div className="system-sm-medium px-1 text-text-secondary">
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeroTagsFilter
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Tag } from '../../hooks'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type HeroTagsTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
onTagsChange,
|
||||
}: HeroTagsTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const hasSelected = !!selectedTagsLength
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
|
||||
!hasSelected && open && 'bg-white/10',
|
||||
!hasSelected && !open && 'hover:bg-white/10',
|
||||
hasSelected && 'border border-white bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
|
||||
)}
|
||||
>
|
||||
<RiPriceTag3Line className={cn(
|
||||
'size-4 shrink-0',
|
||||
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-medium flex items-center gap-0.5">
|
||||
{
|
||||
!hasSelected && (
|
||||
<span>{t('allTags', { ns: 'pluginTags' })}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasSelected && (
|
||||
<span className="text-saas-dify-blue-inverted">
|
||||
{tags.map(tag => tagsMap[tag]?.label).filter(Boolean).slice(0, 2).join(', ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
|
||||
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-saas-dify-blue-inverted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagsChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeroTagsTrigger)
|
||||
@@ -1,4 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export { PluginCategorySwitch } from './plugin'
|
||||
export { TemplateCategorySwitch } from './template'
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType } from '../constants'
|
||||
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { RiArchive2Line } from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
|
||||
import { usePluginCategoryText } from './category-text'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
import HeroTagsFilter from './hero-tags-filter'
|
||||
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
const categoryValues = [
|
||||
PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
] as const
|
||||
|
||||
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
|
||||
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
|
||||
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
|
||||
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
|
||||
}
|
||||
|
||||
export const PluginCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
|
||||
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
const getPluginCategoryText = usePluginCategoryText()
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = categoryValues.map(value => ({
|
||||
value,
|
||||
text: getPluginCategoryText(value, isHeroVariant),
|
||||
icon: getTypeIcon(value, isHeroVariant),
|
||||
}))
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
handleActivePluginCategoryChange(value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHeroVariant) {
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<HeroTagsFilter
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={tags => setFilterPluginTags(tags.length ? tags : null)}
|
||||
/>
|
||||
<div className="text-text-primary-on-surface">
|
||||
·
|
||||
</div>
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user