Compare commits

..

17 Commits

Author SHA1 Message Date
CodingOnStar
3849e444bf refactor(tests): update test imports and improve assertions in plugin test files; remove obsolete constants test file 2026-02-11 12:44:14 +08:00
CodingOnStar
1afc354d97 refactor(eslint): remove unused ESLint suppressions from various plugin components 2026-02-11 11:27:03 +08:00
CodingOnStar
0e36aa9c67 refactor(components): reorder class names for consistency in various plugin components and add unit tests for CardMoreInfo and other components 2026-02-11 11:15:11 +08:00
CodingOnStar
c36de51771 refactor(tests): remove unnecessary comments and improve test clarity across various plugin test files 2026-02-11 01:09:56 +08:00
CodingOnStar
b0b4cac03f feat(tests): add integration tests for plugin functionalities including authentication flow, card rendering, data utilities, installation flow, and marketplace interactions 2026-02-11 00:26:05 +08:00
weiguang li
18f14c04dc fix(web): fill workflow tool output descriptions from schema (#32117) 2026-02-10 16:51:28 +08:00
weiguang li
14251b249d fix(api): include file marker for workflow tool file outputs (#32114) 2026-02-10 16:51:12 +08:00
Stephen Zhou
1819bd72ef refactor: import component css in globals.css (#32180)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-10 13:55:42 +08:00
zyssyz123
7dabc03a08 fix: When the user is a non-sandbox user and has a paid balance, the … (#32173)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-10 12:08:23 +08:00
Dream
1a050c9f86 fix(api): clean up orphaned pending accounts on member removal (#32151)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-10 10:17:27 +08:00
Shuvam Pandey
7fb6e0cdfe refactor(api): tighten OTel decorator typing (#32163)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-02-10 00:46:02 +09:00
Stephen Zhou
e0fcf33979 chore: introduce css icons (#32004) 2026-02-09 18:37:41 +08:00
Stephen Zhou
898e09264b chore: detect utilities in css (#32143) 2026-02-09 18:20:09 +08:00
Vlad D
4ac461d882 fix(api): serialize pipeline file-upload created_at (#32098)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-09 17:50:29 +08:00
Vlad D
fa763216d0 fix(api): register knowledge pipeline service API routes (#32097)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Trigger i18n Sync on Push / trigger (push) Has been cancelled
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
2026-02-09 17:43:36 +08:00
wangxiaolei
d546210040 refactor: document_indexing_sync_task split db session (#32129)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-09 17:12:16 +08:00
Stephen Zhou
4e0a7a7f9e chore: fix type for useTranslation in #i18n (#32134)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-09 16:42:53 +08:00
334 changed files with 18824 additions and 13918 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View File

@@ -6,7 +6,7 @@ const PluginList = () => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
{
!isAllPluginsLoading && !collapse && (
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2"

View File

@@ -63,8 +63,8 @@ const InstallFromMarketplace = ({
{
!isAllPluginsLoading && !collapse && (
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2"

View File

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

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

View File

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

View File

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

View File

@@ -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', () => ({

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
iconWrapperClassName,
textClassName,
}) => {
const { t } = useTranslation()
const { t } = useTranslation('plugin')
const deprecatedReasonKey = useMemo(() => {
if (!deprecatedReason)

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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', () => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
'use client'
export { PluginCategorySwitch } from './plugin'
export { TemplateCategorySwitch } from './template'

View File

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