Compare commits

..

10 Commits

Author SHA1 Message Date
0c29b67e22 Merge remote-tracking branch 'origin/main' into refactor/configuration 2026-01-27 11:43:36 +08:00
c080c48aba refactor(debug): extract hooks and components, add comprehensive tests
Extract reusable hooks and components from debug/index.tsx:
- useInputValidation, useFormattingChangeConfirm, useModalWidth hooks
- useTextCompletion hook for text completion logic
- DebugHeader component for header UI
- TextCompletionResult component for completion display

Add comprehensive test coverage for debug-with-multiple-model:
- chat-item.spec.tsx (23 tests)
- debug-item.spec.tsx (25 tests)
- model-parameter-trigger.spec.tsx (14 tests)
- text-generation-item.spec.tsx (16 tests)
- index.spec.tsx expanded (84 tests)

Total: 183 tests passing with 95%+ coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:42:09 +08:00
lif
d13638f6e4 test: wrap test cleanup in act() to prevent window is not defined error (#31558)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-27 11:25:14 +08:00
b4eef76c14 fix: billing account deletion (#31556) 2026-01-27 11:18:23 +08:00
cbf7f646d9 chore(deps): bump pypdf from 6.6.0 to 6.6.2 in /api (#31568)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-01-27 11:06:13 +08:00
c58647d39c refactor(web): extract MCP components and add comprehensive tests (#31517)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-01-27 11:05:59 +08:00
E.G
f6be9cd90d refactor: replace request.args.get with Pydantic BaseModel validation (#31104)
Co-authored-by: GlobalStar117 <GlobalStar117@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-27 10:48:42 +08:00
360f3bb32f chore(deps): bump pycryptodome from 3.19.1 to 3.23.0 in /api (#31504)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 10:43:05 +08:00
lif
8519b16cfc docs: add ESLint guide to AGENTS.md (#31559)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-27 09:32:55 +08:00
f00d823f9f chore: move agent notes into docstrings (#31560) 2026-01-27 09:32:26 +08:00
56 changed files with 11841 additions and 1790 deletions

View File

@ -25,6 +25,30 @@ pnpm type-check:tsgo
pnpm test
```
### Frontend Linting
ESLint is used for frontend code quality. Available commands:
```bash
# Lint all files (report only)
pnpm lint
# Lint and auto-fix issues
pnpm lint:fix
# Lint specific files or directories
pnpm lint:fix app/components/base/button/
pnpm lint:fix app/components/base/button/index.tsx
# Lint quietly (errors only, no warnings)
pnpm lint:quiet
# Check code complexity
pnpm lint:complexity
```
**Important**: Always run `pnpm lint:fix` before committing. The pre-commit hook runs `lint-staged` which only lints staged files.
## Testing & Quality Practices
- Follow TDD: red → green → refactor.

View File

View File

@ -1,27 +0,0 @@
# Notes: `large_language_model.py`
## Purpose
Provides the base `LargeLanguageModel` implementation used by the model runtime to invoke plugin-backed LLMs and to
bridge plugin daemon streaming semantics back into API-layer entities (`LLMResult`, `LLMResultChunk`).
## Key behaviors / invariants
- `invoke(..., stream=False)` still calls the plugin in streaming mode and then synthesizes a single `LLMResult` from
the first yielded `LLMResultChunk`.
- Plugin invocation is wrapped by `_invoke_llm_via_plugin(...)`, and `stream=False` normalization is handled by
`_normalize_non_stream_plugin_result(...)` / `_build_llm_result_from_first_chunk(...)`.
- Tool call deltas are merged incrementally via `_increase_tool_call(...)` to support multiple provider chunking
patterns (IDs anchored to first chunk, every chunk, or missing entirely).
- A tool-call delta with an empty `id` requires at least one existing tool call; otherwise we raise `ValueError` to
surface invalid delta sequences explicitly.
- Callback invocation is centralized in `_run_callbacks(...)` to ensure consistent error handling/logging.
- For compatibility with dify issue `#17799`, `prompt_messages` may be removed by the plugin daemon in chunks and must
be re-attached in this layer before callbacks/consumers use them.
- Callback hooks (`on_before_invoke`, `on_new_chunk`, `on_after_invoke`, `on_invoke_error`) must not break invocation
unless `callback.raise_error` is true.
## Test focus
- `api/tests/unit_tests/core/model_runtime/__base/test_increase_tool_call.py` validates tool-call delta merging and
patches `_gen_tool_call_id` for deterministic IDs.

View File

@ -1,97 +1,47 @@
# API Agent Guide
## Agent Notes (must-check)
## Notes for Agent (must-check)
Before you start work on any backend file under `api/`, you MUST check whether a related note exists under:
Before changing any backend code under `api/`, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec.
- `agent-notes/<same-relative-path-as-target-file>.md`
Look for:
Rules:
- The module (file) docstring at the top of a source code file
- Docstrings on classes and functions/methods
- Paragraph/block comments for non-obvious logic
- **Path mapping**: for a target file `<path>/<name>.py`, the note must be `agent-notes/<path>/<name>.py.md` (same folder structure, same filename, plus `.md`).
- **Before working**:
- If the note exists, read it first and follow any constraints/decisions recorded there.
- If the note conflicts with the current code, or references an "origin" file/path that has been deleted, renamed, or migrated, treat the **code as the single source of truth** and update the note to match reality.
- If the note does not exist, create it with a short architecture/intent summary and any relevant invariants/edge cases.
- **During working**:
- Keep the note in sync as you discover constraints, make decisions, or change approach.
- If you move/rename a file, migrate its note to the new mapped path (and fix any outdated references inside the note).
- Record non-obvious edge cases, trade-offs, and the test/verification plan as you go (not just at the end).
- Keep notes **coherent**: integrate new findings into the relevant sections and rewrite for clarity; avoid append-only “recent fix” / changelog-style additions unless the note is explicitly intended to be a changelog.
- **When finishing work**:
- Update the related note(s) to reflect what changed, why, and any new edge cases/tests.
- If a file is deleted, remove or clearly deprecate the corresponding note so it cannot be mistaken as current guidance.
- Keep notes concise and accurate; they are meant to prevent repeated rediscovery.
### What to write where
## Skill Index
- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse.
- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing.
- Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard.
- Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes.
- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used).
- If the class is intentionally stateful, note what state exists and what methods mutate it.
- If concurrency/async assumptions matter, state them explicitly.
- **Function/method docstring**: behavioural contract.
- Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions.
- Add examples only when they prevent misuse.
- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states.
- Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality.
Start with the section that best matches your need. Each entry lists the problems it solves plus key files/concepts so you know what to expect before opening it.
### Rules (must follow)
### Platform Foundations
In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments.
#### [Infrastructure Overview](agent_skills/infra.md)
- **When to read this**
- You need to understand where a feature belongs in the architecture.
- Youre wiring storage, Redis, vector stores, or OTEL.
- Youre about to add CLI commands or async jobs.
- **What it covers**
- Configuration stack (`configs/app_config.py`, remote settings)
- Storage entry points (`extensions/ext_storage.py`, `core/file/file_manager.py`)
- Redis conventions (`extensions/ext_redis.py`)
- Plugin runtime topology
- Vector-store factory (`core/rag/datasource/vdb/*`)
- Observability hooks
- SSRF proxy usage
- Core CLI commands
### Plugin & Extension Development
#### [Plugin Systems](agent_skills/plugin.md)
- **When to read this**
- Youre building or debugging a marketplace plugin.
- You need to know how manifests, providers, daemons, and migrations fit together.
- **What it covers**
- Plugin manifests (`core/plugin/entities/plugin.py`)
- Installation/upgrade flows (`services/plugin/plugin_service.py`, CLI commands)
- Runtime adapters (`core/plugin/impl/*` for tool/model/datasource/trigger/endpoint/agent)
- Daemon coordination (`core/plugin/entities/plugin_daemon.py`)
- How provider registries surface capabilities to the rest of the platform
#### [Plugin OAuth](agent_skills/plugin_oauth.md)
- **When to read this**
- You must integrate OAuth for a plugin or datasource.
- Youre handling credential encryption or refresh flows.
- **Topics**
- Credential storage
- Encryption helpers (`core/helper/provider_encryption.py`)
- OAuth client bootstrap (`services/plugin/oauth_service.py`, `services/plugin/plugin_parameter_service.py`)
- How console/API layers expose the flows
### Workflow Entry & Execution
#### [Trigger Concepts](agent_skills/trigger.md)
- **When to read this**
- Youre debugging why a workflow didnt start.
- Youre adding a new trigger type or hook.
- You need to trace async execution, draft debugging, or webhook/schedule pipelines.
- **Details**
- Start-node taxonomy
- Webhook & schedule internals (`core/workflow/nodes/trigger_*`, `services/trigger/*`)
- Async orchestration (`services/async_workflow_service.py`, Celery queues)
- Debug event bus
- Storage/logging interactions
## General Reminders
- All skill docs assume you follow the coding style rules below—run the lint/type/test commands before submitting changes.
- When you cannot find an answer in these briefs, search the codebase using the paths referenced (e.g., `core/plugin/impl/tool.py`, `services/dataset_service.py`).
- If you run into cross-cutting concerns (tenancy, configuration, storage), check the infrastructure guide first; it links to most supporting modules.
- Keep multi-tenancy and configuration central: everything flows through `configs.dify_config` and `tenant_id`.
- When touching plugins or triggers, consult both the system overview and the specialised doc to ensure you adjust lifecycle, storage, and observability consistently.
- **Before working**
- Read the notes in the area youll touch; treat them as part of the spec.
- If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality.
- If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour).
- **During working**
- Keep the notes in sync as you discover constraints, make decisions, or change approach.
- If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants.
- Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct.
- Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions.
- **When finishing**
- Update the notes to reflect what changed, why, and any new edge cases/tests.
- Remove or rewrite any comments that could be mistaken as current guidance but no longer apply.
- Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery.
## Coding Style
@ -226,7 +176,7 @@ Before opening a PR / submitting:
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
- Document non-obvious behaviour with concise comments.
- Document non-obvious behaviour with concise docstrings and comments.
### Miscellaneous

View File

@ -36,6 +36,16 @@ class NotionEstimatePayload(BaseModel):
doc_language: str = Field(default="English")
class DataSourceNotionListQuery(BaseModel):
dataset_id: str | None = Field(default=None, description="Dataset ID")
credential_id: str = Field(..., description="Credential ID", min_length=1)
datasource_parameters: dict[str, Any] | None = Field(default=None, description="Datasource parameters JSON string")
class DataSourceNotionPreviewQuery(BaseModel):
credential_id: str = Field(..., description="Credential ID", min_length=1)
register_schema_model(console_ns, NotionEstimatePayload)
@ -136,26 +146,15 @@ class DataSourceNotionListApi(Resource):
def get(self):
current_user, current_tenant_id = current_account_with_tenant()
dataset_id = request.args.get("dataset_id", default=None, type=str)
credential_id = request.args.get("credential_id", default=None, type=str)
if not credential_id:
raise ValueError("Credential id is required.")
query = DataSourceNotionListQuery.model_validate(request.args.to_dict())
# Get datasource_parameters from query string (optional, for GitHub and other datasources)
datasource_parameters_str = request.args.get("datasource_parameters", default=None, type=str)
datasource_parameters = {}
if datasource_parameters_str:
try:
datasource_parameters = json.loads(datasource_parameters_str)
if not isinstance(datasource_parameters, dict):
raise ValueError("datasource_parameters must be a JSON object.")
except json.JSONDecodeError:
raise ValueError("Invalid datasource_parameters JSON format.")
datasource_parameters = query.datasource_parameters or {}
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=current_tenant_id,
credential_id=credential_id,
credential_id=query.credential_id,
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)
@ -164,8 +163,8 @@ class DataSourceNotionListApi(Resource):
exist_page_ids = []
with Session(db.engine) as session:
# import notion in the exist dataset
if dataset_id:
dataset = DatasetService.get_dataset(dataset_id)
if query.dataset_id:
dataset = DatasetService.get_dataset(query.dataset_id)
if not dataset:
raise NotFound("Dataset not found.")
if dataset.data_source_type != "notion_import":
@ -173,7 +172,7 @@ class DataSourceNotionListApi(Resource):
documents = session.scalars(
select(Document).filter_by(
dataset_id=dataset_id,
dataset_id=query.dataset_id,
tenant_id=current_tenant_id,
data_source_type="notion_import",
enabled=True,
@ -240,13 +239,12 @@ class DataSourceNotionApi(Resource):
def get(self, page_id, page_type):
_, current_tenant_id = current_account_with_tenant()
credential_id = request.args.get("credential_id", default=None, type=str)
if not credential_id:
raise ValueError("Credential id is required.")
query = DataSourceNotionPreviewQuery.model_validate(request.args.to_dict())
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=current_tenant_id,
credential_id=credential_id,
credential_id=query.credential_id,
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)

View File

@ -176,7 +176,18 @@ class IndexingEstimatePayload(BaseModel):
return result
register_schema_models(console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload)
class ConsoleDatasetListQuery(BaseModel):
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
keyword: str | None = Field(default=None, description="Search keyword")
include_all: bool = Field(default=False, description="Include all datasets")
ids: list[str] = Field(default_factory=list, description="Filter by dataset IDs")
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
register_schema_models(
console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload, ConsoleDatasetListQuery
)
def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool = False) -> dict[str, list[str]]:
@ -275,18 +286,19 @@ class DatasetListApi(Resource):
@enterprise_license_required
def get(self):
current_user, current_tenant_id = current_account_with_tenant()
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
ids = request.args.getlist("ids")
query = ConsoleDatasetListQuery.model_validate(request.args.to_dict(flat=False))
# provider = request.args.get("provider", default="vendor")
search = request.args.get("keyword", default=None, type=str)
tag_ids = request.args.getlist("tag_ids")
include_all = request.args.get("include_all", default="false").lower() == "true"
if ids:
datasets, total = DatasetService.get_datasets_by_ids(ids, current_tenant_id)
if query.ids:
datasets, total = DatasetService.get_datasets_by_ids(query.ids, current_tenant_id)
else:
datasets, total = DatasetService.get_datasets(
page, limit, current_tenant_id, current_user, search, tag_ids, include_all
query.page,
query.limit,
current_tenant_id,
current_user,
query.keyword,
query.tag_ids,
query.include_all,
)
# check embedding setting
@ -318,7 +330,13 @@ class DatasetListApi(Resource):
else:
item.update({"partial_member_list": []})
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
response = {
"data": data,
"has_more": len(datasets) == query.limit,
"limit": query.limit,
"total": total,
"page": query.page,
}
return response, 200
@console_ns.doc("create_dataset")

View File

@ -98,12 +98,19 @@ class BedrockRetrievalPayload(BaseModel):
knowledge_id: str
class ExternalApiTemplateListQuery(BaseModel):
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
keyword: str | None = Field(default=None, description="Search keyword")
register_schema_models(
console_ns,
ExternalKnowledgeApiPayload,
ExternalDatasetCreatePayload,
ExternalHitTestingPayload,
BedrockRetrievalPayload,
ExternalApiTemplateListQuery,
)
@ -124,19 +131,17 @@ class ExternalApiTemplateListApi(Resource):
@account_initialization_required
def get(self):
_, current_tenant_id = current_account_with_tenant()
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
search = request.args.get("keyword", default=None, type=str)
query = ExternalApiTemplateListQuery.model_validate(request.args.to_dict())
external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis(
page, limit, current_tenant_id, search
query.page, query.limit, current_tenant_id, query.keyword
)
response = {
"data": [item.to_dict() for item in external_knowledge_apis],
"has_more": len(external_knowledge_apis) == limit,
"limit": limit,
"has_more": len(external_knowledge_apis) == query.limit,
"limit": query.limit,
"total": total,
"page": page,
"page": query.page,
}
return response, 200

View File

@ -3,7 +3,7 @@ from typing import Any
from flask import request
from flask_restx import Resource, marshal_with
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
@ -28,6 +28,10 @@ class InstalledAppUpdatePayload(BaseModel):
is_pinned: bool | None = None
class InstalledAppsListQuery(BaseModel):
app_id: str | None = Field(default=None, description="App ID to filter by")
logger = logging.getLogger(__name__)
@ -37,13 +41,13 @@ class InstalledAppsListApi(Resource):
@account_initialization_required
@marshal_with(installed_app_list_fields)
def get(self):
app_id = request.args.get("app_id", default=None, type=str)
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
current_user, current_tenant_id = current_account_with_tenant()
if app_id:
if query.app_id:
installed_apps = db.session.scalars(
select(InstalledApp).where(
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == app_id)
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == query.app_id)
)
).all()
else:

View File

@ -40,6 +40,7 @@ register_schema_models(
TagBasePayload,
TagBindingPayload,
TagBindingRemovePayload,
TagListQueryParam,
)

View File

@ -87,6 +87,14 @@ class TagUnbindingPayload(BaseModel):
target_id: str
class DatasetListQuery(BaseModel):
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
keyword: str | None = Field(default=None, description="Search keyword")
include_all: bool = Field(default=False, description="Include all datasets")
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
register_schema_models(
service_api_ns,
DatasetCreatePayload,
@ -96,6 +104,7 @@ register_schema_models(
TagDeletePayload,
TagBindingPayload,
TagUnbindingPayload,
DatasetListQuery,
)
@ -113,15 +122,11 @@ class DatasetListApi(DatasetApiResource):
)
def get(self, tenant_id):
"""Resource for getting datasets."""
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
query = DatasetListQuery.model_validate(request.args.to_dict(flat=False))
# provider = request.args.get("provider", default="vendor")
search = request.args.get("keyword", default=None, type=str)
tag_ids = request.args.getlist("tag_ids")
include_all = request.args.get("include_all", default="false").lower() == "true"
datasets, total = DatasetService.get_datasets(
page, limit, tenant_id, current_user, search, tag_ids, include_all
query.page, query.limit, tenant_id, current_user, query.keyword, query.tag_ids, query.include_all
)
# check embedding setting
provider_manager = ProviderManager()
@ -147,7 +152,13 @@ class DatasetListApi(DatasetApiResource):
item["embedding_available"] = False
else:
item["embedding_available"] = True
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
response = {
"data": data,
"has_more": len(datasets) == query.limit,
"limit": query.limit,
"total": total,
"page": query.page,
}
return response, 200
@service_api_ns.expect(service_api_ns.models[DatasetCreatePayload.__name__])

View File

@ -69,7 +69,14 @@ class DocumentTextUpdate(BaseModel):
return self
for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate]:
class DocumentListQuery(BaseModel):
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
keyword: str | None = Field(default=None, description="Search keyword")
status: str | None = Field(default=None, description="Document status filter")
for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate, DocumentListQuery]:
service_api_ns.schema_model(m.__name__, m.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) # type: ignore
@ -460,34 +467,33 @@ class DocumentListApi(DatasetApiResource):
def get(self, tenant_id, dataset_id):
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
search = request.args.get("keyword", default=None, type=str)
status = request.args.get("status", default=None, type=str)
query_params = DocumentListQuery.model_validate(request.args.to_dict())
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
if not dataset:
raise NotFound("Dataset not found.")
query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=tenant_id)
if status:
query = DocumentService.apply_display_status_filter(query, status)
if query_params.status:
query = DocumentService.apply_display_status_filter(query, query_params.status)
if search:
search = f"%{search}%"
if query_params.keyword:
search = f"%{query_params.keyword}%"
query = query.where(Document.name.like(search))
query = query.order_by(desc(Document.created_at), desc(Document.position))
paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
paginated_documents = db.paginate(
select=query, page=query_params.page, per_page=query_params.limit, max_per_page=100, error_out=False
)
documents = paginated_documents.items
response = {
"data": marshal(documents, document_fields),
"has_more": len(documents) == limit,
"limit": limit,
"has_more": len(documents) == query_params.limit,
"limit": query_params.limit,
"total": paginated_documents.total,
"page": page,
"page": query_params.page,
}
return response

View File

@ -1,4 +1,4 @@
from collections.abc import Generator, Mapping
from collections.abc import Generator
from typing import Any
from core.datasource.__base.datasource_plugin import DatasourcePlugin
@ -34,7 +34,7 @@ class OnlineDocumentDatasourcePlugin(DatasourcePlugin):
def get_online_document_pages(
self,
user_id: str,
datasource_parameters: Mapping[str, Any],
datasource_parameters: dict[str, Any],
provider_type: str,
) -> Generator[OnlineDocumentPagesMessage, None, None]:
manager = PluginDatasourceManager()

View File

@ -64,7 +64,7 @@ dependencies = [
"pandas[excel,output-formatting,performance]~=2.2.2",
"psycogreen~=1.0.2",
"psycopg2-binary~=2.9.6",
"pycryptodome==3.19.1",
"pycryptodome==3.23.0",
"pydantic~=2.11.4",
"pydantic-extra-types~=2.10.3",
"pydantic-settings~=2.11.0",

View File

@ -131,7 +131,7 @@ class BillingService:
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
url = f"{cls.base_url}{endpoint}"
response = httpx.request(method, url, json=json, params=params, headers=headers)
response = httpx.request(method, url, json=json, params=params, headers=headers, follow_redirects=True)
if method == "GET" and response.status_code != httpx.codes.OK:
raise ValueError("Unable to retrieve billing information. Please try again later or contact support.")
if method == "PUT":
@ -143,6 +143,9 @@ class BillingService:
raise ValueError("Invalid arguments.")
if method == "POST" and response.status_code != httpx.codes.OK:
raise ValueError(f"Unable to send request to {url}. Please try again later or contact support.")
if method == "DELETE" and response.status_code != httpx.codes.OK:
logger.error("billing_service: DELETE response: %s %s", response.status_code, response.text)
raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.")
return response.json()
@staticmethod
@ -165,7 +168,7 @@ class BillingService:
def delete_account(cls, account_id: str):
"""Delete account."""
params = {"account_id": account_id}
return cls._send_request("DELETE", "/account/", params=params)
return cls._send_request("DELETE", "/account", params=params)
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:

View File

@ -171,22 +171,26 @@ class TestBillingServiceSendRequest:
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
)
def test_delete_request_non_200_with_valid_json(self, mock_httpx_request, mock_billing_config, status_code):
"""Test DELETE request with non-200 status code but valid JSON response.
"""Test DELETE request with non-200 status code raises ValueError.
DELETE doesn't check status code, so it returns the error JSON.
DELETE now checks status code and raises ValueError for non-200 responses.
"""
# Arrange
error_response = {"detail": "Error message"}
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.text = "Error message"
mock_response.json.return_value = error_response
mock_httpx_request.return_value = mock_response
# Act
result = BillingService._send_request("DELETE", "/test", json={"key": "value"})
# Assert
assert result == error_response
# Act & Assert
with patch("services.billing_service.logger") as mock_logger:
with pytest.raises(ValueError) as exc_info:
BillingService._send_request("DELETE", "/test", json={"key": "value"})
assert "Unable to process delete request" in str(exc_info.value)
# Verify error logging
mock_logger.error.assert_called_once()
assert "DELETE response" in str(mock_logger.error.call_args)
@pytest.mark.parametrize(
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
@ -210,9 +214,9 @@ class TestBillingServiceSendRequest:
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
)
def test_delete_request_non_200_with_invalid_json(self, mock_httpx_request, mock_billing_config, status_code):
"""Test DELETE request with non-200 status code and invalid JSON response raises exception.
"""Test DELETE request with non-200 status code raises ValueError before JSON parsing.
DELETE doesn't check status code, so it calls response.json() which raises JSONDecodeError
DELETE now checks status code before calling response.json(), so ValueError is raised
when the response cannot be parsed as JSON (e.g., empty response).
"""
# Arrange
@ -223,8 +227,13 @@ class TestBillingServiceSendRequest:
mock_httpx_request.return_value = mock_response
# Act & Assert
with pytest.raises(json.JSONDecodeError):
BillingService._send_request("DELETE", "/test", json={"key": "value"})
with patch("services.billing_service.logger") as mock_logger:
with pytest.raises(ValueError) as exc_info:
BillingService._send_request("DELETE", "/test", json={"key": "value"})
assert "Unable to process delete request" in str(exc_info.value)
# Verify error logging
mock_logger.error.assert_called_once()
assert "DELETE response" in str(mock_logger.error.call_args)
def test_retry_on_request_error(self, mock_httpx_request, mock_billing_config):
"""Test that _send_request retries on httpx.RequestError."""
@ -789,7 +798,7 @@ class TestBillingServiceAccountManagement:
# Assert
assert result == expected_response
mock_send_request.assert_called_once_with("DELETE", "/account/", params={"account_id": account_id})
mock_send_request.assert_called_once_with("DELETE", "/account", params={"account_id": account_id})
def test_is_email_in_freeze_true(self, mock_send_request):
"""Test checking if email is frozen (returns True)."""

33
api/uv.lock generated
View File

@ -1633,7 +1633,7 @@ requires-dist = [
{ name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=2.2.2" },
{ name = "psycogreen", specifier = "~=1.0.2" },
{ name = "psycopg2-binary", specifier = "~=2.9.6" },
{ name = "pycryptodome", specifier = "==3.19.1" },
{ name = "pycryptodome", specifier = "==3.23.0" },
{ name = "pydantic", specifier = "~=2.11.4" },
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
{ name = "pydantic-settings", specifier = "~=2.11.0" },
@ -4796,20 +4796,21 @@ wheels = [
[[package]]
name = "pycryptodome"
version = "3.19.1"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/38/42a8855ff1bf568c61ca6557e2203f318fb7afeadaf2eb8ecfdbde107151/pycryptodome-3.19.1.tar.gz", hash = "sha256:8ae0dd1bcfada451c35f9e29a3e5db385caabc190f98e4a80ad02a61098fb776", size = 4782144, upload-time = "2023-12-28T06:52:40.741Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/ef/4931bc30674f0de0ca0e827b58c8b0c17313a8eae2754976c610b866118b/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:67939a3adbe637281c611596e44500ff309d547e932c449337649921b17b6297", size = 2417027, upload-time = "2023-12-28T06:51:50.138Z" },
{ url = "https://files.pythonhosted.org/packages/67/e6/238c53267fd8d223029c0a0d3730cb1b6594d60f62e40c4184703dc490b1/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:11ddf6c9b52116b62223b6a9f4741bc4f62bb265392a4463282f7f34bb287180", size = 1579728, upload-time = "2023-12-28T06:51:52.385Z" },
{ url = "https://files.pythonhosted.org/packages/7c/87/7181c42c8d5ba89822a4b824830506d0aeec02959bb893614767e3279846/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e6f89480616781d2a7f981472d0cdb09b9da9e8196f43c1234eff45c915766", size = 2051440, upload-time = "2023-12-28T06:51:55.751Z" },
{ url = "https://files.pythonhosted.org/packages/34/dd/332c4c0055527d17dac317ed9f9c864fc047b627d82f4b9a56c110afc6fc/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e1efcb68993b7ce5d1d047a46a601d41281bba9f1971e6be4aa27c69ab8065", size = 2125379, upload-time = "2023-12-28T06:51:58.567Z" },
{ url = "https://files.pythonhosted.org/packages/24/9e/320b885ea336c218ff54ec2b276cd70ba6904e4f5a14a771ed39a2c47d59/pycryptodome-3.19.1-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c6273ca5a03b672e504995529b8bae56da0ebb691d8ef141c4aa68f60765700", size = 2153951, upload-time = "2023-12-28T06:52:01.699Z" },
{ url = "https://files.pythonhosted.org/packages/f4/54/8ae0c43d1257b41bc9d3277c3f875174fd8ad86b9567f0b8609b99c938ee/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b0bfe61506795877ff974f994397f0c862d037f6f1c0bfc3572195fc00833b96", size = 2044041, upload-time = "2023-12-28T06:52:03.737Z" },
{ url = "https://files.pythonhosted.org/packages/45/93/f8450a92cc38541c3ba1f4cb4e267e15ae6d6678ca617476d52c3a3764d4/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:f34976c5c8eb79e14c7d970fb097482835be8d410a4220f86260695ede4c3e17", size = 2182446, upload-time = "2023-12-28T06:52:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ed6e429fb0792ce368f66e83246264dd3a7a045b0b1e63043ed22a063ce5/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7c9e222d0976f68d0cf6409cfea896676ddc1d98485d601e9508f90f60e2b0a2", size = 2144914, upload-time = "2023-12-28T06:52:07.44Z" },
{ url = "https://files.pythonhosted.org/packages/f6/23/b064bd4cfbf2cc5f25afcde0e7c880df5b20798172793137ba4b62d82e72/pycryptodome-3.19.1-cp35-abi3-win32.whl", hash = "sha256:4805e053571140cb37cf153b5c72cd324bb1e3e837cbe590a19f69b6cf85fd03", size = 1713105, upload-time = "2023-12-28T06:52:09.585Z" },
{ url = "https://files.pythonhosted.org/packages/7d/e0/ded1968a5257ab34216a0f8db7433897a2337d59e6d03be113713b346ea2/pycryptodome-3.19.1-cp35-abi3-win_amd64.whl", hash = "sha256:a470237ee71a1efd63f9becebc0ad84b88ec28e6784a2047684b693f458f41b7", size = 1749222, upload-time = "2023-12-28T06:52:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
@ -5003,11 +5004,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.6.0"
version = "6.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/f4/801632a8b62a805378b6af2b5a3fcbfd8923abf647e0ed1af846a83433b2/pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f", size = 5281063, upload-time = "2026-01-09T11:20:11.786Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/ba/96f99276194f720e74ed99905a080f6e77810558874e8935e580331b46de/pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7", size = 328963, upload-time = "2026-01-09T11:20:09.278Z" },
{ url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" },
]
[[package]]

View File

@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import type { ModelAndParameter } from './types'
import {
RiAddLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import TooltipPlus from '@/app/components/base/tooltip'
import { AppModeEnum } from '@/types/app'
type DebugHeaderProps = {
readonly?: boolean
mode: AppModeEnum
debugWithMultipleModel: boolean
multipleModelConfigs: ModelAndParameter[]
varListLength: number
expanded: boolean
onExpandedChange: (expanded: boolean) => void
onClearConversation: () => void
onAddModel: () => void
}
const DebugHeader: FC<DebugHeaderProps> = ({
readonly,
mode,
debugWithMultipleModel,
multipleModelConfigs,
varListLength,
expanded,
onExpandedChange,
onClearConversation,
onAddModel,
}) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="flex items-center">
{debugWithMultipleModel && (
<>
<Button
variant="ghost-accent"
onClick={onAddModel}
disabled={multipleModelConfigs.length >= 4}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('modelProvider.addModel', { ns: 'common' })}
(
{multipleModelConfigs.length}
/4)
</Button>
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
</>
)}
{mode !== AppModeEnum.COMPLETION && (
<>
{!readonly && (
<TooltipPlus popupContent={t('operation.refresh', { ns: 'common' })}>
<ActionButton onClick={onClearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
)}
{varListLength > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus popupContent={t('panel.userInputField', { ns: 'workflow' })}>
<ActionButton
state={expanded ? ActionButtonState.Active : undefined}
onClick={() => !readonly && onExpandedChange(!expanded)}
>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && (
<div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />
)}
</div>
)}
</>
)}
</div>
</div>
)
}
export default DebugHeader

View File

@ -0,0 +1,737 @@
import type { ModelAndParameter } from '../types'
import type { ChatConfig } from '@/app/components/base/chat/types'
import { render, screen, waitFor } from '@testing-library/react'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { ModelModeType } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types'
import ChatItem from './chat-item'
const mockUseAppContext = vi.fn()
const mockUseDebugConfigurationContext = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseFeatures = vi.fn()
const mockUseConfigFromDebugContext = vi.fn()
const mockUseFormattingChangedSubscription = vi.fn()
const mockUseChat = vi.fn()
const mockUseEventEmitterContextContext = vi.fn()
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
}))
vi.mock('../hooks', () => ({
useConfigFromDebugContext: () => mockUseConfigFromDebugContext(),
useFormattingChangedSubscription: (chatList: unknown) => mockUseFormattingChangedSubscription(chatList),
}))
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
useChat: (...args: unknown[]) => mockUseChat(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
const mockStopChatMessageResponding = vi.fn()
const mockFetchConversationMessages = vi.fn()
const mockFetchSuggestedQuestions = vi.fn()
vi.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
}))
vi.mock('@/utils', () => ({
canFindTool: (collectionId: string, providerId: string) => collectionId === providerId,
}))
vi.mock('@/app/components/base/chat/utils', () => ({
getLastAnswer: (chatList: { id: string }[]) => chatList.length > 0 ? chatList[chatList.length - 1] : null,
}))
let capturedChatProps: Record<string, unknown> | null = null
vi.mock('@/app/components/base/chat/chat', () => ({
default: (props: Record<string, unknown>) => {
capturedChatProps = props
return <div data-testid="chat-component">Chat</div>
},
}))
vi.mock('@/app/components/base/avatar', () => ({
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
const createDefaultModelConfig = () => ({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string' as const },
{ key: 'api-var', name: 'API Var', type: 'api' as const },
],
},
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
})
const createDefaultFeatures = () => ({
moreLikeThis: { enabled: false },
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
moderation: { enabled: false },
speech2text: { enabled: true },
text2speech: { enabled: false },
file: { enabled: true, image: { enabled: true } },
suggested: { enabled: true },
citation: { enabled: false },
annotationReply: { enabled: false },
})
const createTextGenerationModelList = (models: Array<{
provider: string
model: string
features?: string[]
mode?: string
}> = []) => {
const providerMap = new Map<string, { model: string, features: string[], model_properties: { mode: string } }[]>()
for (const m of models) {
if (!providerMap.has(m.provider)) {
providerMap.set(m.provider, [])
}
providerMap.get(m.provider)!.push({
model: m.model,
features: m.features ?? [],
model_properties: { mode: m.mode ?? 'chat' },
})
}
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
provider,
models: modelsList,
}))
}
describe('ChatItem', () => {
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedChatProps = null
subscriptionCallback = null
mockUseAppContext.mockReturnValue({
userProfile: { avatar_url: 'avatar.png', name: 'Test User' },
})
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: { name: 'World' },
collectionList: [],
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-3.5-turbo', features: [ModelFeatureEnum.vision], mode: 'chat' },
{ provider: 'openai', model: 'gpt-4', features: [], mode: 'chat' },
]),
})
const features = createDefaultFeatures()
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
mockUseConfigFromDebugContext.mockReturnValue({
baseConfig: true,
})
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1', content: 'Hello' }],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: {
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
subscriptionCallback = callback
},
},
})
})
describe('rendering', () => {
it('should render Chat component when chatList is not empty', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
it('should return null when chatList is empty', () => {
mockUseChat.mockReturnValue({
chatList: [],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter()
const { container } = render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(container.firstChild).toBeNull()
})
it('should pass correct props to Chat component', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(capturedChatProps!.noChatInput).toBe(true)
expect(capturedChatProps!.noStopResponding).toBe(true)
expect(capturedChatProps!.showPromptLog).toBe(true)
expect(capturedChatProps!.hideLogModal).toBe(true)
expect(capturedChatProps!.noSpacing).toBe(true)
expect(capturedChatProps!.chatContainerClassName).toBe('p-4')
expect(capturedChatProps!.chatFooterClassName).toBe('p-4 pb-0')
})
})
describe('config building', () => {
it('should merge configTemplate with features', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig & { baseConfig?: boolean }
expect(config.baseConfig).toBe(true)
expect(config.more_like_this).toEqual({ enabled: false })
expect(config.opening_statement).toBe('Hello')
expect(config.suggested_questions).toEqual(['Q1'])
expect(config.speech_to_text).toEqual({ enabled: true })
expect(config.file_upload).toEqual({ enabled: true, image: { enabled: true } })
})
it('should use empty opening_statement when opening is disabled', () => {
const features = createDefaultFeatures()
features.opening = { enabled: false, opening_statement: 'Hello', suggested_questions: ['Q1'] }
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.opening_statement).toBe('')
expect(config.suggested_questions).toEqual([])
})
it('should use empty string fallback when opening_statement is undefined', () => {
const features = createDefaultFeatures()
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
features.opening = { enabled: true, opening_statement: undefined as any, suggested_questions: ['Q1'] }
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.opening_statement).toBe('')
})
it('should use empty array fallback when suggested_questions is undefined', () => {
const features = createDefaultFeatures()
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
features.opening = { enabled: true, opening_statement: 'Hello', suggested_questions: undefined as any }
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.suggested_questions).toEqual([])
})
it('should handle undefined opening feature', () => {
const features = createDefaultFeatures()
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
features.opening = undefined as any
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.opening_statement).toBe('')
expect(config.suggested_questions).toEqual([])
})
})
describe('inputsForm transformation', () => {
it('should filter out api type variables and map to InputForm', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// The useChat is called with inputsForm
const useChatCall = mockUseChat.mock.calls[0]
const inputsForm = useChatCall[1].inputsForm
expect(inputsForm).toHaveLength(1)
expect(inputsForm[0]).toEqual(expect.objectContaining({
key: 'name',
label: 'Name',
variable: 'name',
}))
})
})
describe('event subscription', () => {
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// Trigger the event
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test message', files: [{ id: 'file-1' }] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
})
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL_RESTART event', async () => {
const handleRestart = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart,
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// Trigger the event
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
})
await waitFor(() => {
expect(handleRestart).toHaveBeenCalled()
})
})
})
describe('doSend', () => {
it('should find current provider and model from textGenerationModelList', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
'apps/test-app-id/chat-messages',
expect.objectContaining({
query: 'test',
inputs: { name: 'World' },
model_config: expect.objectContaining({
model: expect.objectContaining({
provider: 'openai',
name: 'gpt-3.5-turbo',
mode: 'chat',
}),
}),
}),
expect.any(Object),
)
})
})
it('should include files when file upload is enabled and vision is supported', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// gpt-3.5-turbo has vision feature
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
const files = [{ id: 'file-1', name: 'image.png' }]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
files,
}),
expect.any(Object),
)
})
})
it('should not include files when vision is not supported', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// gpt-4 does not have vision feature
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
const files = [{ id: 'file-1', name: 'image.png' }]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
it('should handle provider not found in textGenerationModelList', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// Use a provider that doesn't exist in the list
const modelAndParameter = createModelAndParameter({ provider: 'unknown-provider', model: 'unknown-model' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [{ id: 'file-1' }] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
const callArgs = handleSend.mock.calls[0][1]
// Files should not be included when provider/model not found (no vision support)
expect(callArgs.files).toBeUndefined()
})
})
it('should handle model with no features array', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// Model list where model has no features property
mockUseProviderContext.mockReturnValue({
textGenerationModelList: [
{
provider: 'custom',
models: [{ model: 'custom-model', model_properties: { mode: 'chat' } }],
},
],
})
const modelAndParameter = createModelAndParameter({ provider: 'custom', model: 'custom-model' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [{ id: 'file-1' }] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
const callArgs = handleSend.mock.calls[0][1]
// Files should not be included when features is undefined
expect(callArgs.files).toBeUndefined()
})
})
it('should handle undefined files parameter', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: undefined },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
})
describe('tool icons building', () => {
it('should build tool icons from agent config', () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
...createDefaultModelConfig(),
agentConfig: {
tools: [
{ tool_name: 'search', provider_id: 'provider-1' },
{ tool_name: 'calculator', provider_id: 'provider-2' },
],
},
},
appId: 'test-app-id',
inputs: {},
collectionList: [
{ id: 'provider-1', icon: 'search-icon' },
{ id: 'provider-2', icon: 'calc-icon' },
],
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(capturedChatProps!.allToolIcons).toEqual({
search: 'search-icon',
calculator: 'calc-icon',
})
})
it('should handle missing tools gracefully', () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
...createDefaultModelConfig(),
agentConfig: {
tools: undefined,
},
},
appId: 'test-app-id',
inputs: {},
collectionList: [],
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(capturedChatProps!.allToolIcons).toEqual({})
})
})
describe('useFormattingChangedSubscription', () => {
it('should call useFormattingChangedSubscription with chatList', () => {
const chatList = [{ id: 'msg-1' }, { id: 'msg-2' }]
mockUseChat.mockReturnValue({
chatList,
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(mockUseFormattingChangedSubscription).toHaveBeenCalledWith(chatList)
})
})
describe('useChat callbacks', () => {
it('should pass stopChatMessageResponding callback to useChat', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// Get the stopResponding callback passed to useChat (4th argument)
const useChatCall = mockUseChat.mock.calls[0]
const stopRespondingCallback = useChatCall[3]
// Invoke it with a taskId
stopRespondingCallback('test-task-id')
expect(mockStopChatMessageResponding).toHaveBeenCalledWith('test-app-id', 'test-task-id')
})
it('should pass onGetConversationMessages callback to handleSend', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
// Get the callbacks object (3rd argument to handleSend)
const callbacks = handleSend.mock.calls[0][2]
// Invoke onGetConversationMessages
const mockGetAbortController = vi.fn()
callbacks.onGetConversationMessages('conv-123', mockGetAbortController)
expect(mockFetchConversationMessages).toHaveBeenCalledWith('test-app-id', 'conv-123', mockGetAbortController)
})
it('should pass onGetSuggestedQuestions callback to handleSend', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
// Get the callbacks object (3rd argument to handleSend)
const callbacks = handleSend.mock.calls[0][2]
// Invoke onGetSuggestedQuestions
const mockGetAbortController = vi.fn()
callbacks.onGetSuggestedQuestions('response-item-123', mockGetAbortController)
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('test-app-id', 'response-item-123', mockGetAbortController)
})
})
})

View File

@ -0,0 +1,599 @@
import type { ModelAndParameter } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { AppModeEnum } from '@/types/app'
import DebugItem from './debug-item'
const mockUseTranslation = vi.fn()
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseProviderContext = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('./context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('./chat-item', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="chat-item" data-model-id={modelAndParameter.id}>ChatItem</div>
),
}))
vi.mock('./text-generation-item', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="text-generation-item" data-model-id={modelAndParameter.id}>TextGenerationItem</div>
),
}))
vi.mock('./model-parameter-trigger', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="model-parameter-trigger" data-model-id={modelAndParameter.id}>ModelParameterTrigger</div>
),
}))
type DropdownItem = { value: string, text: string }
type DropdownProps = {
items?: DropdownItem[]
secondItems?: DropdownItem[]
onSelect: (item: DropdownItem) => void
}
let capturedDropdownProps: DropdownProps | null = null
vi.mock('@/app/components/base/dropdown', () => ({
default: (props: DropdownProps) => {
capturedDropdownProps = props
return (
<div data-testid="dropdown">
<button
type="button"
data-testid="dropdown-trigger"
onClick={() => {
// Mock dropdown menu showing items
}}
>
Dropdown
</button>
{props.items?.map((item: DropdownItem) => (
<button
key={item.value}
type="button"
data-testid={`dropdown-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
{props.secondItems?.map((item: DropdownItem) => (
<button
key={item.value}
type="button"
data-testid={`dropdown-second-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
</div>
)
},
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: {},
...overrides,
})
const createTextGenerationModelList = (models: Array<{ provider: string, model: string, status?: ModelStatusEnum }> = []) => {
const providerMap = new Map<string, { model: string, status: ModelStatusEnum, model_properties: { mode: string }, features: string[] }[]>()
for (const m of models) {
if (!providerMap.has(m.provider)) {
providerMap.set(m.provider, [])
}
providerMap.get(m.provider)!.push({
model: m.model,
status: m.status ?? ModelStatusEnum.active,
model_properties: { mode: 'chat' },
features: [],
})
}
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
provider,
models: modelsList,
}))
}
describe('DebugItem', () => {
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedDropdownProps = null
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: [],
})
})
describe('rendering', () => {
it('should render with index number', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByText('#1')).toBeInTheDocument()
})
it('should render correct index for second model', () => {
const model1 = createModelAndParameter({ id: 'model-a' })
const model2 = createModelAndParameter({ id: 'model-b' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={model2} />)
expect(screen.getByText('#2')).toBeInTheDocument()
})
it('should render ModelParameterTrigger', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
})
it('should render Dropdown', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
})
it('should apply custom className', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply custom style', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} style={{ width: '300px' }} />)
expect(container.firstChild).toHaveStyle({ width: '300px' })
})
})
describe('ChatItem rendering', () => {
it('should render ChatItem in CHAT mode with active model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
})
it('should render ChatItem in AGENT_CHAT mode with active model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.AGENT_CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
})
it('should not render ChatItem when model is not active', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
})
it('should not render ChatItem when provider not found', () => {
const modelAndParameter = createModelAndParameter({ provider: 'unknown', model: 'model' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
})
})
describe('TextGenerationItem rendering', () => {
it('should render TextGenerationItem in COMPLETION mode with active model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.COMPLETION,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('text-generation-item')).toBeInTheDocument()
})
it('should not render TextGenerationItem when model is not active', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.COMPLETION,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
})
it('should not render TextGenerationItem in CHAT mode', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
})
})
describe('dropdown menu items', () => {
it('should show duplicate option when less than 4 models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
})
it('should hide duplicate option when 4 or more models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
modelAndParameter,
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).not.toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
})
it('should show debug-as-single-model when provider and model are set', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
})
it('should hide debug-as-single-model when provider is missing', () => {
const modelAndParameter = createModelAndParameter({ provider: '', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
})
it('should hide debug-as-single-model when model is missing', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: '' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
})
it('should show remove option when more than 2 models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, createModelAndParameter(), createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.secondItems).toContainEqual(
expect.objectContaining({ value: 'remove' }),
)
})
it('should hide remove option when 2 or fewer models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.secondItems).toBeUndefined()
})
})
describe('dropdown actions', () => {
it('should duplicate model when clicking duplicate', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const model2 = createModelAndParameter({ id: 'model-b' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, model2],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
expect.arrayContaining([
expect.objectContaining({ id: 'model-a' }),
expect.objectContaining({ provider: 'openai', model: 'gpt-4' }),
expect.objectContaining({ id: 'model-b' }),
]),
)
expect(onMultipleModelConfigsChange.mock.calls[0][1]).toHaveLength(3)
})
it('should not duplicate when already at 4 models', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
modelAndParameter,
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
// Duplicate option should not be rendered when at 4 models
expect(screen.queryByTestId('dropdown-item-duplicate')).not.toBeInTheDocument()
})
it('should early return when trying to duplicate with 4 models via handleSelect', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
modelAndParameter,
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
// Directly call handleSelect with duplicate action to cover line 42
capturedDropdownProps!.onSelect({ value: 'duplicate', text: 'Duplicate' })
// Should not call onMultipleModelConfigsChange due to early return
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
})
it('should call onDebugWithMultipleModelChange when clicking debug-as-single-model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
const onDebugWithMultipleModelChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange,
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
it('should remove model when clicking remove', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const model2 = createModelAndParameter({ id: 'model-b' })
const model3 = createModelAndParameter({ id: 'model-c' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, model2, model3],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-b' }),
expect.objectContaining({ id: 'model-c' }),
],
)
})
})
})

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { DebugWithMultipleModelContextType } from './context'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { EnableType } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import {
memo,
@ -40,13 +41,7 @@ const DebugWithMultipleModel = () => {
if (checkCanSend && !checkCanSend())
return
eventEmitter?.emit({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message,
files,
},
} as any)
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message, files } } as any) // eslint-disable-line ts/no-explicit-any
}, [eventEmitter, checkCanSend])
const twoLine = multipleModelConfigs.length === 2
@ -147,7 +142,7 @@ const DebugWithMultipleModel = () => {
showFileUpload={false}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
onSend={handleSend}
speechToTextConfig={speech2text as any}
speechToTextConfig={speech2text as EnableType}
visionConfig={file}
inputs={inputs}
inputsForm={inputsForm}

View File

@ -0,0 +1,436 @@
import type * as React from 'react'
import type { ModelAndParameter } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelParameterTrigger from './model-parameter-trigger'
// Mock MODEL_STATUS_TEXT that is imported in the component
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', async (importOriginal) => {
const original = await importOriginal() as object
return {
...original,
MODEL_STATUS_TEXT: {
'disabled': { en_US: 'Disabled', zh_Hans: '已禁用' },
'quota-exceeded': { en_US: 'Quota Exceeded', zh_Hans: '配额已用完' },
'no-configure': { en_US: 'No Configure', zh_Hans: '未配置凭据' },
},
}
})
const mockUseTranslation = vi.fn()
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseLanguage = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('./context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockUseLanguage(),
}))
type RenderTriggerParams = {
open: boolean
currentProvider: { provider: string, icon: string } | null
currentModel: { model: string, status: ModelStatusEnum } | null
}
type ModalProps = {
provider: string
modelId: string
isAdvancedMode: boolean
completionParams: Record<string, unknown>
debugWithMultipleModel: boolean
setModel: (model: { modelId: string, provider: string }) => void
onCompletionParamsChange: (params: Record<string, unknown>) => void
onDebugWithMultipleModelChange: () => void
renderTrigger: (params: RenderTriggerParams) => React.ReactElement
}
let capturedModalProps: ModalProps | null = null
let mockRenderTriggerFn: ((params: RenderTriggerParams) => React.ReactElement) | null = null
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: (props: ModalProps) => {
capturedModalProps = props
mockRenderTriggerFn = props.renderTrigger
// Render the trigger with some mock data
const triggerElement = props.renderTrigger({
open: false,
currentProvider: props.provider
? { provider: props.provider, icon: 'provider-icon' }
: null,
currentModel: props.modelId
? { model: props.modelId, status: ModelStatusEnum.active }
: null,
})
return (
<div data-testid="model-parameter-modal">
{triggerElement}
<button
type="button"
data-testid="select-model-btn"
onClick={() => props.setModel({ modelId: 'new-model', provider: 'new-provider' })}
>
Select Model
</button>
<button
type="button"
data-testid="change-params-btn"
onClick={() => props.onCompletionParamsChange({ temperature: 0.9 })}
>
Change Params
</button>
<button
type="button"
data-testid="debug-single-btn"
onClick={() => props.onDebugWithMultipleModelChange()}
>
Debug Single
</button>
</div>
)
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ provider, modelName }: { provider: { provider: string } | null, modelName?: string }) => (
<div data-testid="model-icon" data-provider={provider?.provider} data-model={modelName}>
ModelIcon
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } | null }) => (
<div data-testid="model-name" data-model={modelItem?.model}>
{modelItem?.model}
</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/line/shapes', () => ({
CubeOutline: () => <div data-testid="cube-icon">CubeOutline</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback', () => ({
AlertTriangle: () => <div data-testid="alert-icon">AlertTriangle</div>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
describe('ModelParameterTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedModalProps = null
mockRenderTriggerFn = null
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseLanguage.mockReturnValue('en_US')
})
describe('rendering', () => {
it('should render ModelParameterModal with correct props', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
expect(capturedModalProps!.isAdvancedMode).toBe(false)
expect(capturedModalProps!.provider).toBe('openai')
expect(capturedModalProps!.modelId).toBe('gpt-4')
expect(capturedModalProps!.completionParams).toEqual({ temperature: 0.7 })
expect(capturedModalProps!.debugWithMultipleModel).toBe(true)
})
it('should pass isAdvancedMode from context', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: true,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(capturedModalProps!.isAdvancedMode).toBe(true)
})
})
describe('trigger rendering', () => {
it('should render model icon when provider exists', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should render cube icon when no provider', () => {
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('cube-icon')).toBeInTheDocument()
})
it('should render model name when model exists', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-name')).toBeInTheDocument()
})
it('should render select model text when no model', () => {
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByText('modelProvider.selectModel')).toBeInTheDocument()
})
})
describe('handleSelectModel', () => {
it('should update model and provider in configs', () => {
const model1 = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-3.5' })
const model2 = createModelAndParameter({ id: 'model-b' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model1} />)
fireEvent.click(screen.getByTestId('select-model-btn'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-a', model: 'new-model', provider: 'new-provider' }),
expect.objectContaining({ id: 'model-b' }),
],
)
})
it('should update correct model when multiple configs exist', () => {
const model1 = createModelAndParameter({ id: 'model-a' })
const model2 = createModelAndParameter({ id: 'model-b' })
const model3 = createModelAndParameter({ id: 'model-c' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2, model3],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model2} />)
fireEvent.click(screen.getByTestId('select-model-btn'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-a' }),
expect.objectContaining({ id: 'model-b', model: 'new-model', provider: 'new-provider' }),
expect.objectContaining({ id: 'model-c' }),
],
)
})
})
describe('handleParamsChange', () => {
it('should update parameters in configs', () => {
const model1 = createModelAndParameter({ id: 'model-a', parameters: { temperature: 0.5 } })
const model2 = createModelAndParameter({ id: 'model-b' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model1} />)
fireEvent.click(screen.getByTestId('change-params-btn'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-a', parameters: { temperature: 0.9 } }),
expect.objectContaining({ id: 'model-b' }),
],
)
})
})
describe('onDebugWithMultipleModelChange', () => {
it('should call onDebugWithMultipleModelChange with current modelAndParameter', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const onDebugWithMultipleModelChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange,
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('debug-single-btn'))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
})
describe('index finding', () => {
it('should find correct index for model in middle of array', () => {
const model1 = createModelAndParameter({ id: 'model-a' })
const model2 = createModelAndParameter({ id: 'model-b' })
const model3 = createModelAndParameter({ id: 'model-c' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2, model3],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model2} />)
// Verify that the correct index is used by checking the result of handleSelectModel
fireEvent.click(screen.getByTestId('select-model-btn'))
// The second model (index 1) should be updated
const updatedConfigs = onMultipleModelConfigsChange.mock.calls[0][1]
expect(updatedConfigs[0].id).toBe('model-a')
expect(updatedConfigs[1].model).toBe('new-model') // This one should be updated
expect(updatedConfigs[2].id).toBe('model-c')
})
})
describe('renderTrigger styling and states', () => {
it('should render trigger with open state styling', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
// Call renderTrigger with open=true to test the open styling branch
const triggerWithOpen = mockRenderTriggerFn!({
open: true,
currentProvider: { provider: 'openai', icon: 'provider-icon' },
currentModel: { model: 'gpt-4', status: ModelStatusEnum.active },
})
expect(triggerWithOpen).toBeDefined()
})
it('should render warning tooltip when model status is not active', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
// Call renderTrigger with inactive model status to test the warning branch
const triggerWithInactiveModel = mockRenderTriggerFn!({
open: false,
currentProvider: { provider: 'openai', icon: 'provider-icon' },
currentModel: { model: 'gpt-4', status: ModelStatusEnum.disabled },
})
expect(triggerWithInactiveModel).toBeDefined()
})
it('should render warning background and tooltip for inactive model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
// Test with quota_exceeded status (another inactive status)
const triggerWithQuotaExceeded = mockRenderTriggerFn!({
open: false,
currentProvider: { provider: 'openai', icon: 'provider-icon' },
currentModel: { model: 'gpt-4', status: ModelStatusEnum.quotaExceeded },
})
expect(triggerWithQuotaExceeded).toBeDefined()
})
})
})

View File

@ -0,0 +1,621 @@
import type { ModelAndParameter } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import { TransferMethod } from '@/app/components/base/chat/types'
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { ModelModeType } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
import TextGenerationItem from './text-generation-item'
const mockUseDebugConfigurationContext = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseFeatures = vi.fn()
const mockUseTextGeneration = vi.fn()
const mockUseEventEmitterContextContext = vi.fn()
const mockPromptVariablesToUserInputsForm = vi.fn()
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
}))
vi.mock('@/app/components/base/text-generation/hooks', () => ({
useTextGeneration: () => mockUseTextGeneration(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
vi.mock('@/utils/model-config', () => ({
promptVariablesToUserInputsForm: (vars: unknown) => mockPromptVariablesToUserInputsForm(vars),
}))
let capturedTextGenerationProps: Record<string, unknown> | null = null
vi.mock('@/app/components/app/text-generate/item', () => ({
default: (props: Record<string, unknown>) => {
capturedTextGenerationProps = props
return <div data-testid="text-generation-component">TextGeneration</div>
},
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
const createDefaultModelConfig = () => ({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.completion,
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
{ key: 'context', name: 'Context', type: 'string' as const, is_context_var: true },
],
},
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
})
const createDefaultFeatures = () => ({
moreLikeThis: { enabled: true },
moderation: { enabled: false },
text2speech: { enabled: true },
file: { enabled: true },
})
const createTextGenerationModelList = (models: Array<{
provider: string
model: string
mode?: string
}> = []) => {
const providerMap = new Map<string, { model: string, model_properties: { mode: string } }[]>()
for (const m of models) {
if (!providerMap.has(m.provider)) {
providerMap.set(m.provider, [])
}
providerMap.get(m.provider)!.push({
model: m.model,
model_properties: { mode: m.mode ?? 'completion' },
})
}
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
provider,
models: modelsList,
}))
}
describe('TextGenerationItem', () => {
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedTextGenerationProps = null
subscriptionCallback = null
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: { name: 'World' },
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: 'Welcome',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [{ id: 'ds-1', name: 'Dataset 1' }],
datasetConfigs: { retrieval_model: 'single' },
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-3.5-turbo', mode: 'completion' },
{ provider: 'openai', model: 'gpt-4', mode: 'completion' },
]),
})
const features = createDefaultFeatures()
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
mockUseTextGeneration.mockReturnValue({
completion: 'Generated text',
handleSend: vi.fn(),
isResponding: false,
messageId: 'msg-1',
})
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: {
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
subscriptionCallback = callback
},
},
})
mockPromptVariablesToUserInputsForm.mockReturnValue([
{ key: 'name', label: 'Name', variable: 'name' },
])
})
describe('rendering', () => {
it('should render TextGeneration component', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('text-generation-component')).toBeInTheDocument()
})
it('should pass correct props to TextGeneration component', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(capturedTextGenerationProps!.content).toBe('Generated text')
expect(capturedTextGenerationProps!.isLoading).toBe(false)
expect(capturedTextGenerationProps!.isResponding).toBe(false)
expect(capturedTextGenerationProps!.messageId).toBe('msg-1')
expect(capturedTextGenerationProps!.isError).toBe(false)
expect(capturedTextGenerationProps!.inSidePanel).toBe(true)
expect(capturedTextGenerationProps!.siteInfo).toBeNull()
})
it('should show loading state when no completion and is responding', () => {
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend: vi.fn(),
isResponding: true,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(capturedTextGenerationProps!.isLoading).toBe(true)
})
it('should not show loading state when has completion', () => {
mockUseTextGeneration.mockReturnValue({
completion: 'Some text',
handleSend: vi.fn(),
isResponding: true,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(capturedTextGenerationProps!.isLoading).toBe(false)
})
})
describe('config building', () => {
it('should build config with correct pre_prompt in simple mode', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
// The config is built internally, we verify via the handleSend call
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
pre_prompt: 'Hello {{name}}',
}),
}),
)
})
it('should use empty pre_prompt in advanced mode', () => {
mockUseDebugConfigurationContext.mockReturnValue({
...mockUseDebugConfigurationContext(),
isAdvancedMode: true,
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: {},
promptMode: 'advanced',
speechToTextConfig: { enabled: true },
introduction: '',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [],
datasetConfigs: { retrieval_model: 'single' },
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
pre_prompt: '',
}),
}),
)
})
it('should find context variable from prompt_variables', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
dataset_query_variable: 'context',
}),
}),
)
})
it('should use empty string for dataset_query_variable when no context var exists', () => {
const modelConfigWithoutContextVar = {
...createDefaultModelConfig(),
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
],
},
}
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: modelConfigWithoutContextVar,
appId: 'test-app-id',
inputs: { name: 'World' },
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: 'Welcome',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [],
datasetConfigs: { retrieval_model: 'single' },
})
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
dataset_query_variable: '',
}),
}),
)
})
})
describe('datasets transformation', () => {
it('should transform dataSets to postDatasets format', () => {
mockUseDebugConfigurationContext.mockReturnValue({
...mockUseDebugConfigurationContext(),
isAdvancedMode: false,
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: {},
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: '',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
],
datasetConfigs: { retrieval_model: 'single' },
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
dataset_configs: expect.objectContaining({
datasets: {
datasets: [
{ dataset: { enabled: true, id: 'ds-1' } },
{ dataset: { enabled: true, id: 'ds-2' } },
],
},
}),
}),
}),
)
})
})
describe('event subscription', () => {
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test message', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
'apps/test-app-id/completion-messages',
expect.any(Object),
)
})
})
it('should ignore non-matching events', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: 'SOME_OTHER_EVENT',
payload: { message: 'test' },
})
expect(handleSend).not.toHaveBeenCalled()
})
})
describe('doSend', () => {
it('should build config data with model info', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter({
provider: 'openai',
model: 'gpt-3.5-turbo',
parameters: { temperature: 0.8 },
})
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
model: {
provider: 'openai',
name: 'gpt-3.5-turbo',
mode: 'completion',
completion_params: { temperature: 0.8 },
},
}),
}),
)
})
})
it('should process local files by clearing url', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
const files = [
{ id: 'file-1', transfer_method: TransferMethod.local_file, url: 'http://example.com/file1' },
{ id: 'file-2', transfer_method: TransferMethod.remote_url, url: 'http://example.com/file2' },
]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files[0].url).toBe('')
expect(callArgs.files[1].url).toBe('http://example.com/file2')
})
})
it('should not include files when file upload is disabled', async () => {
const features = { ...createDefaultFeatures(), file: { enabled: false } }
mockUseFeatures.mockImplementation((selector: (state: { features: typeof features }) => unknown) => selector({ features }))
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
const files = [{ id: 'file-1', transfer_method: TransferMethod.remote_url }]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
it('should not include files when no files provided', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
})
describe('features integration', () => {
it('should include features in config', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
more_like_this: { enabled: true },
sensitive_word_avoidance: { enabled: false },
text_to_speech: { enabled: true },
file_upload: { enabled: true },
}),
}),
)
})
})
})

View File

@ -6,18 +6,26 @@ import type {
ChatConfig,
ChatItem,
} from '@/app/components/base/chat/types'
import type { VisionFile } from '@/types/app'
import { cloneDeep } from 'es-toolkit/object'
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
AgentStrategy,
AppModeEnum,
ModelModeType,
TransferMethod,
} from '@/types/app'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { ORCHESTRATE_CHANGED } from './types'
@ -162,3 +170,111 @@ export const useFormattingChangedSubscription = (chatList: ChatItem[]) => {
}
})
}
export const useInputValidation = () => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const {
isAdvancedMode,
mode,
modelModeType,
hasSetBlockStatus,
modelConfig,
} = useDebugConfigurationContext()
const logError = useCallback((message: string) => {
notify({ type: 'error', message })
}, [notify])
const checkCanSend = useCallback((inputs: Record<string, unknown>, completionFiles: VisionFile[]) => {
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
return false
}
if (!hasSetBlockStatus.query) {
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
return false
}
}
}
let hasEmptyInput = ''
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
return false
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
})
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return !hasEmptyInput
}, [
hasSetBlockStatus.history,
hasSetBlockStatus.query,
isAdvancedMode,
mode,
modelConfig.configs.prompt_variables,
t,
logError,
notify,
modelModeType,
])
return { checkCanSend, logError }
}
export const useFormattingChangeConfirm = () => {
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
const { formattingChanged, setFormattingChanged } = useDebugConfigurationContext()
useEffect(() => {
if (formattingChanged)
setIsShowFormattingChangeConfirm(true) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}, [formattingChanged])
const handleConfirm = useCallback((onClear: () => void) => {
onClear()
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}, [setFormattingChanged])
const handleCancel = useCallback(() => {
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}, [setFormattingChanged])
return {
isShowFormattingChangeConfirm,
handleConfirm,
handleCancel,
}
}
export const useModalWidth = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const [width, setWidth] = useState(0)
useEffect(() => {
if (containerRef.current) {
const calculatedWidth = document.body.clientWidth - (containerRef.current.clientWidth + 16) - 8
setWidth(calculatedWidth) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}
}, [containerRef])
return width
}

View File

@ -3,54 +3,39 @@ import type { FC } from 'react'
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
import type { ModelAndParameter } from './types'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { Inputs } from '@/models/debug'
import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app'
import {
RiAddLine,
RiEqualizer2Line,
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import type { Inputs, PromptVariable } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
import { produce, setAutoFreeze } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
import { useStore as useAppStore } from '@/app/components/app/store'
import TextGeneration from '@/app/components/app/text-generate/item'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Button from '@/app/components/base/button'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { ToastContext } from '@/app/components/base/toast'
import TooltipPlus from '@/app/components/base/tooltip'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
import { IS_CE_EDITION } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { sendCompletionMessage } from '@/service/debug'
import { AppSourceType } from '@/service/share'
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
import GroupName from '../base/group-name'
import { AppModeEnum } from '@/types/app'
import CannotQueryDataset from '../base/warning-mask/cannot-query-dataset'
import FormattingChanged from '../base/warning-mask/formatting-changed'
import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
import DebugHeader from './debug-header'
import DebugWithMultipleModel from './debug-with-multiple-model'
import DebugWithSingleModel from './debug-with-single-model'
import { useFormattingChangeConfirm, useInputValidation, useModalWidth } from './hooks'
import TextCompletionResult from './text-completion-result'
import {
APP_CHAT_WITH_MULTIPLE_MODEL,
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
} from './types'
import { useTextCompletion } from './use-text-completion'
type IDebug = {
isAPIKeySet: boolean
@ -71,33 +56,17 @@ const Debug: FC<IDebug> = ({
multipleModelConfigs,
onMultipleModelConfigsChange,
}) => {
const { t } = useTranslation()
const {
readonly,
appId,
mode,
modelModeType,
hasSetBlockStatus,
isAdvancedMode,
promptMode,
chatPromptConfig,
completionPromptConfig,
introduction,
suggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
textToSpeechConfig,
citationConfig,
formattingChanged,
setFormattingChanged,
dataSets,
modelConfig,
completionParams,
hasSetContextVar,
datasetConfigs,
externalDataToolsConfig,
} = useContext(ConfigContext)
const { eventEmitter } = useEventEmitterContextContext()
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const features = useFeatures(s => s.features)
const featuresStore = useFeaturesStore()
// Disable immer auto-freeze for this component
useEffect(() => {
setAutoFreeze(false)
return () => {
@ -105,226 +74,77 @@ const Debug: FC<IDebug> = ({
}
}, [])
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
// UI state
const [expanded, setExpanded] = useState(true)
const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
useEffect(() => {
if (formattingChanged)
setIsShowFormattingChangeConfirm(true)
}, [formattingChanged])
const containerRef = useRef<HTMLDivElement>(null)
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
const handleClearConversation = () => {
debugWithSingleModelRef.current?.handleRestart()
}
const clearConversation = async () => {
if (debugWithMultipleModel) {
eventEmitter?.emit({
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
} as any)
return
}
handleClearConversation()
}
// Hooks
const { checkCanSend } = useInputValidation()
const { isShowFormattingChangeConfirm, handleConfirm, handleCancel } = useFormattingChangeConfirm()
const modalWidth = useModalWidth(containerRef)
const handleConfirm = () => {
clearConversation()
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}
// Wrapper for checkCanSend that uses current completionFiles
const [completionFilesForValidation, setCompletionFilesForValidation] = useState<VisionFile[]>([])
const checkCanSendWithFiles = useCallback(() => {
return checkCanSend(inputs, completionFilesForValidation)
}, [checkCanSend, inputs, completionFilesForValidation])
const handleCancel = () => {
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}
const { notify } = useContext(ToastContext)
const logError = useCallback((message: string) => {
notify({ type: 'error', message })
}, [notify])
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const checkCanSend = useCallback(() => {
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
return false
}
if (!hasSetBlockStatus.query) {
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
return false
}
}
}
let hasEmptyInput = ''
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
return false
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
}) // compatible with old version
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return !hasEmptyInput
}, [
const {
isResponding,
completionRes,
messageId,
completionFiles,
hasSetBlockStatus.history,
hasSetBlockStatus.query,
inputs,
isAdvancedMode,
mode,
modelConfig.configs.prompt_variables,
t,
logError,
notify,
modelModeType,
])
const [completionRes, setCompletionRes] = useState('')
const [messageId, setMessageId] = useState<string | null>(null)
const features = useFeatures(s => s.features)
const featuresStore = useFeaturesStore()
const sendTextCompletion = async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
if (dataSets.length > 0 && !hasSetContextVar) {
setShowCannotQueryDataset(true)
return true
}
if (!checkCanSend())
return
const postDatasets = dataSets.map(({ id }) => ({
dataset: {
enabled: true,
id,
},
}))
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
const postModelConfig: BackendModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
dataset_query_variable: contextVar || '',
dataset_configs: {
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
} as any,
},
agent_mode: {
enabled: false,
tools: [],
},
model: {
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams as any,
},
more_like_this: features.moreLikeThis as any,
sensitive_word_avoidance: features.moderation as any,
text_to_speech: features.text2speech as any,
file_upload: features.file as any,
opening_statement: introduction,
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
speech_to_text: speechToTextConfig,
retriever_resource: citationConfig,
system_parameters: modelConfig.system_parameters,
external_data_tools: externalDataToolsConfig,
}
const data: Record<string, any> = {
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
model_config: postModelConfig,
}
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
setCompletionRes('')
setMessageId('')
let res: string[] = []
setRespondingTrue()
sendCompletionMessage(appId, data, {
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
res.push(data)
setCompletionRes(res.join(''))
setMessageId(messageId)
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onCompleted() {
setRespondingFalse()
},
onError() {
setRespondingFalse()
},
})
}
const handleSendTextCompletion = () => {
if (debugWithMultipleModel) {
eventEmitter?.emit({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message: '',
files: completionFiles,
},
} as any)
return
}
sendTextCompletion()
}
const varList = modelConfig.configs.prompt_variables.map((item: any) => {
return {
label: item.key,
value: inputs[item.key],
}
setCompletionFiles,
sendTextCompletion,
} = useTextCompletion({
checkCanSend: checkCanSendWithFiles,
onShowCannotQueryDataset: () => setShowCannotQueryDataset(true),
})
// Sync completionFiles for validation
useEffect(() => {
setCompletionFilesForValidation(completionFiles as VisionFile[]) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}, [completionFiles])
// App store for modals
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
// Provider context for model list
const { textGenerationModelList } = useProviderContext()
const handleChangeToSingleModel = (item: ModelAndParameter) => {
// Computed values
const varList = modelConfig.configs.prompt_variables.map((item: PromptVariable) => ({
label: item.key,
value: inputs[item.key],
}))
// Handlers
const handleClearConversation = useCallback(() => {
debugWithSingleModelRef.current?.handleRestart()
}, [])
const clearConversation = useCallback(async () => {
if (debugWithMultipleModel) {
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } as any) // eslint-disable-line ts/no-explicit-any
return
}
handleClearConversation()
}, [debugWithMultipleModel, eventEmitter, handleClearConversation])
const handleFormattingConfirm = useCallback(() => {
handleConfirm(clearConversation)
}, [handleConfirm, clearConversation])
const handleChangeToSingleModel = useCallback((item: ModelAndParameter) => {
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider)
const currentModel = currentProvider?.models.find(model => model.model === item.model)
@ -335,26 +155,18 @@ const Debug: FC<IDebug> = ({
features: currentModel?.features,
})
modelParameterParams.onCompletionParamsChange(item.parameters)
onMultipleModelConfigsChange(
false,
[],
)
}
onMultipleModelConfigsChange(false, [])
}, [modelParameterParams, onMultipleModelConfigsChange, textGenerationModelList])
const handleVisionConfigInMultipleModel = useCallback(() => {
if (debugWithMultipleModel && mode) {
const supportedVision = multipleModelConfigs.some((modelConfig) => {
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === modelConfig.provider)
const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model)
const supportedVision = multipleModelConfigs.some((config) => {
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === config.provider)
const currentModel = currentProvider?.models.find(model => model.model === config.model)
return currentModel?.features?.includes(ModelFeatureEnum.vision)
})
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
const { features: storeFeatures, setFeatures } = featuresStore!.getState()
const newFeatures = produce(storeFeatures, (draft) => {
draft.file = {
...draft.file,
enabled: supportedVision,
@ -368,210 +180,131 @@ const Debug: FC<IDebug> = ({
handleVisionConfigInMultipleModel()
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
const [width, setWidth] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const handleSendTextCompletion = useCallback(() => {
if (debugWithMultipleModel) {
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message: '', files: completionFiles } } as any) // eslint-disable-line ts/no-explicit-any
return
}
sendTextCompletion()
}, [completionFiles, debugWithMultipleModel, eventEmitter, sendTextCompletion])
const adjustModalWidth = () => {
if (ref.current)
setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8)
}
const handleAddModel = useCallback(() => {
onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])
}, [multipleModelConfigs, onMultipleModelConfigsChange])
useEffect(() => {
adjustModalWidth()
}, [])
const handleClosePromptLogModal = useCallback(() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}, [setCurrentLogItem, setShowPromptLogModal])
const [expanded, setExpanded] = useState(true)
const handleCloseAgentLogModal = useCallback(() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}, [setCurrentLogItem, setShowAgentLogModal])
const isShowTextToSpeech = features.text2speech?.enabled && !!text2speechDefaultModel
return (
<>
<div className="shrink-0">
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="flex items-center">
{
debugWithMultipleModel
? (
<>
<Button
variant="ghost-accent"
onClick={() => onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])}
disabled={multipleModelConfigs.length >= 4}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('modelProvider.addModel', { ns: 'common' })}
(
{multipleModelConfigs.length}
/4)
</Button>
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
</>
)
: null
}
{mode !== AppModeEnum.COMPLETION && (
<>
{
!readonly && (
<TooltipPlus
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
)
}
{
varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)
}
</>
)}
</div>
</div>
<DebugHeader
readonly={readonly}
mode={mode}
debugWithMultipleModel={debugWithMultipleModel}
multipleModelConfigs={multipleModelConfigs}
varListLength={varList.length}
expanded={expanded}
onExpandedChange={setExpanded}
onClearConversation={clearConversation}
onAddModel={handleAddModel}
/>
{mode !== AppModeEnum.COMPLETION && expanded && (
<div className="mx-3">
<ChatUserInput inputs={inputs} />
</div>
)}
{
mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)
}
</div>
{
debugWithMultipleModel && (
<div className="mt-3 grow overflow-hidden" ref={ref}>
<DebugWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={handleChangeToSingleModel}
checkCanSend={checkCanSend}
/>
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</div>
)
}
{
!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={ref}>
{/* Chat */}
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
<DebugWithSingleModel
ref={debugWithSingleModelRef}
checkCanSend={checkCanSend}
/>
</div>
)}
{/* Text Generation */}
{mode === AppModeEnum.COMPLETION && (
<>
{(completionRes || isResponding) && (
<>
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
<div className="mx-3 mb-8">
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionRes}
isLoading={!completionRes && isResponding}
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
isResponding={isResponding}
messageId={messageId}
isError={false}
onRetry={noop}
siteInfo={null}
/>
</div>
</>
)}
{!completionRes && !isResponding && (
<div className="flex grow flex-col items-center justify-center gap-2">
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
</div>
)}
</>
)}
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{isShowCannotQueryDataset && (
<CannotQueryDataset
onConfirm={() => setShowCannotQueryDataset(false)}
/>
)}
</div>
)
}
{
isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleConfirm}
onCancel={handleCancel}
{mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
)}
</div>
{debugWithMultipleModel && (
<div className="mt-3 grow overflow-hidden" ref={containerRef}>
<DebugWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={handleChangeToSingleModel}
checkCanSend={checkCanSendWithFiles}
/>
{showPromptLogModal && (
<PromptLogModal
width={modalWidth}
currentLogItem={currentLogItem}
onCancel={handleClosePromptLogModal}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={modalWidth}
currentLogItem={currentLogItem}
onCancel={handleCloseAgentLogModal}
/>
)}
</div>
)}
{!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={containerRef}>
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
<DebugWithSingleModel
ref={debugWithSingleModelRef}
checkCanSend={checkCanSendWithFiles}
/>
</div>
)}
{mode === AppModeEnum.COMPLETION && (
<TextCompletionResult
completionRes={completionRes}
isResponding={isResponding}
messageId={messageId}
isShowTextToSpeech={isShowTextToSpeech}
/>
)}
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
<PromptLogModal
width={modalWidth}
currentLogItem={currentLogItem}
onCancel={handleClosePromptLogModal}
/>
)}
{isShowCannotQueryDataset && (
<CannotQueryDataset onConfirm={() => setShowCannotQueryDataset(false)} />
)}
</div>
)}
{isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleFormattingConfirm}
onCancel={handleCancel}
/>
)}
{!isAPIKeySet && !readonly && (
<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />
)}
</>
)
}
export default React.memo(Debug)

View File

@ -0,0 +1,57 @@
'use client'
import type { FC } from 'react'
import { RiSparklingFill } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useTranslation } from 'react-i18next'
import TextGeneration from '@/app/components/app/text-generate/item'
import { AppSourceType } from '@/service/share'
import GroupName from '../base/group-name'
type TextCompletionResultProps = {
completionRes: string
isResponding: boolean
messageId: string | null
isShowTextToSpeech?: boolean
}
const TextCompletionResult: FC<TextCompletionResultProps> = ({
completionRes,
isResponding,
messageId,
isShowTextToSpeech,
}) => {
const { t } = useTranslation()
if (!completionRes && !isResponding) {
return (
<div className="flex grow flex-col items-center justify-center gap-2">
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
</div>
)
}
return (
<>
<div className="mx-4 mt-3">
<GroupName name={t('result', { ns: 'appDebug' })} />
</div>
<div className="mx-3 mb-8">
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionRes}
isLoading={!completionRes && isResponding}
isShowTextToSpeech={isShowTextToSpeech}
isResponding={isResponding}
messageId={messageId}
isError={false}
onRetry={noop}
siteInfo={null}
/>
</div>
</>
)
}
export default TextCompletionResult

View File

@ -0,0 +1,187 @@
import type { ModelConfig as BackendModelConfig, VisionFile } from '@/types/app'
import { useBoolean } from 'ahooks'
import { cloneDeep } from 'es-toolkit/object'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useFeatures } from '@/app/components/base/features/hooks'
import { ToastContext } from '@/app/components/base/toast'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { sendCompletionMessage } from '@/service/debug'
import { TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
type UseTextCompletionOptions = {
checkCanSend: () => boolean
onShowCannotQueryDataset: () => void
}
export const useTextCompletion = ({
checkCanSend,
onShowCannotQueryDataset,
}: UseTextCompletionOptions) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const {
appId,
isAdvancedMode,
promptMode,
chatPromptConfig,
completionPromptConfig,
introduction,
suggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
citationConfig,
dataSets,
modelConfig,
completionParams,
hasSetContextVar,
datasetConfigs,
externalDataToolsConfig,
inputs,
} = useDebugConfigurationContext()
const features = useFeatures(s => s.features)
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [completionRes, setCompletionRes] = useState('')
const [messageId, setMessageId] = useState<string | null>(null)
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const sendTextCompletion = useCallback(async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
if (dataSets.length > 0 && !hasSetContextVar) {
onShowCannotQueryDataset()
return true
}
if (!checkCanSend())
return
const postDatasets = dataSets.map(({ id }) => ({
dataset: {
enabled: true,
id,
},
}))
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
const postModelConfig: BackendModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
dataset_query_variable: contextVar || '',
/* eslint-disable ts/no-explicit-any */
dataset_configs: {
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
} as any,
},
agent_mode: {
enabled: false,
tools: [],
},
model: {
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams as any,
},
more_like_this: features.moreLikeThis as any,
sensitive_word_avoidance: features.moderation as any,
text_to_speech: features.text2speech as any,
file_upload: features.file as any,
/* eslint-enable ts/no-explicit-any */
opening_statement: introduction,
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
speech_to_text: speechToTextConfig,
retriever_resource: citationConfig,
system_parameters: modelConfig.system_parameters,
external_data_tools: externalDataToolsConfig,
}
// eslint-disable-next-line ts/no-explicit-any
const data: Record<string, any> = {
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
model_config: postModelConfig,
}
// eslint-disable-next-line ts/no-explicit-any
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
setCompletionRes('')
setMessageId('')
let res: string[] = []
setRespondingTrue()
sendCompletionMessage(appId, data, {
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
res.push(data)
setCompletionRes(res.join(''))
setMessageId(messageId)
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onCompleted() {
setRespondingFalse()
},
onError() {
setRespondingFalse()
},
})
}, [
appId,
checkCanSend,
chatPromptConfig,
citationConfig,
completionFiles,
completionParams,
completionPromptConfig,
datasetConfigs,
dataSets,
externalDataToolsConfig,
features,
hasSetContextVar,
inputs,
introduction,
isAdvancedMode,
isResponding,
modelConfig,
notify,
onShowCannotQueryDataset,
promptMode,
setRespondingFalse,
setRespondingTrue,
speechToTextConfig,
suggestedQuestionsAfterAnswerConfig,
t,
])
return {
isResponding,
completionRes,
messageId,
completionFiles,
setCompletionFiles,
sendTextCompletion,
}
}

View File

@ -0,0 +1,221 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewMCPCard from './create-card'
// Track the mock functions
const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' })
// Mock the service
vi.mock('@/service/use-tools', () => ({
useCreateMCP: () => ({
mutateAsync: mockCreateMCP,
}),
}))
// Mock the MCP Modal
type MockMCPModalProps = {
show: boolean
onConfirm: (info: { name: string, server_url: string }) => void
onHide: () => void
}
vi.mock('./modal', () => ({
default: ({ show, onConfirm, onHide }: MockMCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-modal">
<span>tools.mcp.modal.title</span>
<button data-testid="confirm-btn" onClick={() => onConfirm({ name: 'Test MCP', server_url: 'https://test.com' })}>
Confirm
</button>
<button data-testid="close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
describe('NewMCPCard', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
handleCreate: vi.fn(),
}
beforeEach(() => {
mockCreateMCP.mockClear()
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
})
it('should render card title', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardLink')).toBeInTheDocument()
})
it('should render add icon', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const svgElements = document.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThan(0)
})
})
describe('User Interactions', () => {
it('should open modal when card is clicked', async () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
}
})
it('should have documentation link with correct target', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const docLink = screen.getByText('tools.mcp.create.cardLink').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
describe('Non-Manager User', () => {
it('should not render card when user is not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct card structure', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const card = document.querySelector('.rounded-xl')
expect(card).toBeInTheDocument()
})
it('should have clickable cursor style', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const card = document.querySelector('.cursor-pointer')
expect(card).toBeInTheDocument()
})
})
describe('Modal Interactions', () => {
it('should call create function when modal confirms', async () => {
const handleCreate = vi.fn()
render(<NewMCPCard handleCreate={handleCreate} />, { wrapper: createWrapper() })
// Open the modal
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Click confirm
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockCreateMCP).toHaveBeenCalledWith({
name: 'Test MCP',
server_url: 'https://test.com',
})
expect(handleCreate).toHaveBeenCalled()
})
}
})
it('should close modal when close button is clicked', async () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the modal
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Click close
const closeBtn = screen.getByTestId('close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
})
}
})
})
})

View File

@ -0,0 +1,855 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPDetailContent from './content'
// Mutable mock functions
const mockUpdateTools = vi.fn().mockResolvedValue({})
const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockInvalidateMCPTools = vi.fn()
const mockOpenOAuthPopup = vi.fn()
// Mutable mock state
type MockTool = {
id: string
name: string
description?: string
}
let mockToolsData: { tools: MockTool[] } = { tools: [] }
let mockIsFetching = false
let mockIsUpdating = false
let mockIsAuthorizing = false
// Mock the services
vi.mock('@/service/use-tools', () => ({
useMCPTools: () => ({
data: mockToolsData,
isFetching: mockIsFetching,
}),
useInvalidateMCPTools: () => mockInvalidateMCPTools,
useUpdateMCPTools: () => ({
mutateAsync: mockUpdateTools,
isPending: mockIsUpdating,
}),
useAuthorizeMCP: () => ({
mutateAsync: mockAuthorizeMcp,
isPending: mockIsAuthorizing,
}),
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock OAuth hook
type OAuthArgs = readonly unknown[]
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: (...args: OAuthArgs) => mockOpenOAuthPopup(...args),
}))
// Mock MCPModal
type MCPModalData = {
name: string
server_url: string
}
type MCPModalProps = {
show: boolean
onConfirm: (data: MCPModalData) => void
onHide: () => void
}
vi.mock('../modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-update-modal">
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
Confirm
</button>
<button data-testid="modal-close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mock Confirm dialog
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog" data-title={title}>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
},
}))
// Mock OperationDropdown
vi.mock('./operation-dropdown', () => ({
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
<div data-testid="operation-dropdown">
<button data-testid="edit-btn" onClick={onEdit}>Edit</button>
<button data-testid="remove-btn" onClick={onRemove}>Remove</button>
</div>
),
}))
// Mock ToolItem
type ToolItemData = {
name: string
}
vi.mock('./tool-item', () => ({
default: ({ tool }: { tool: ToolItemData }) => (
<div data-testid="tool-item">{tool.name}</div>
),
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock copy-to-clipboard
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
describe('MCPDetailContent', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockDetail = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
server_identifier: 'test-mcp',
server_url: 'https://example.com/mcp',
icon: { content: '🔧', background: '#FF0000' },
tools: [],
is_team_authorization: false,
...overrides,
} as unknown as ToolWithProvider)
const defaultProps = {
detail: createMockDetail(),
onUpdate: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
}
beforeEach(() => {
// Reset mocks
mockUpdateTools.mockClear()
mockAuthorizeMcp.mockClear()
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockInvalidateMCPTools.mockClear()
mockOpenOAuthPopup.mockClear()
// Reset mock return values
mockUpdateTools.mockResolvedValue({})
mockAuthorizeMcp.mockResolvedValue({ result: 'success' })
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
// Reset state
mockToolsData = { tools: [] }
mockIsFetching = false
mockIsUpdating = false
mockIsAuthorizing = false
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display MCP name', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display server identifier', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-mcp')).toBeInTheDocument()
})
it('should display server URL', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument()
})
it('should render close button', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Close button should be present
const closeButtons = document.querySelectorAll('button')
expect(closeButtons.length).toBeGreaterThan(0)
})
it('should render operation dropdown', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Operation dropdown trigger should be present
expect(document.querySelector('button')).toBeInTheDocument()
})
})
describe('Authorization State', () => {
it('should show authorize button when not authorized', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument()
})
it('should show authorized button when authorized', () => {
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
it('should show authorization required message when not authorized', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument()
})
it('should show authorization tip', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorizeTip')).toBeInTheDocument()
})
})
describe('Empty Tools State', () => {
it('should show empty message when authorized but no tools', () => {
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.toolsEmpty')).toBeInTheDocument()
})
it('should show get tools button when empty', () => {
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument()
})
})
describe('Icon Display', () => {
it('should render MCP icon', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Icon container should be present
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty server URL', () => {
const detail = createMockDetail({ server_url: '' })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should handle long MCP name', () => {
const longName = 'A'.repeat(100)
const detail = createMockDetail({ name: longName })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(longName)).toBeInTheDocument()
})
})
describe('Tools List', () => {
it('should show tools list when authorized and has tools', () => {
mockToolsData = {
tools: [
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tool1')).toBeInTheDocument()
expect(screen.getByText('tool2')).toBeInTheDocument()
})
it('should show single tool label when only one tool', () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.onlyTool')).toBeInTheDocument()
})
it('should show tools count when multiple tools', () => {
mockToolsData = {
tools: [
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(/tools.mcp.toolsNum/)).toBeInTheDocument()
})
})
describe('Loading States', () => {
it('should show loading state when fetching tools', () => {
mockIsFetching = true
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.gettingTools')).toBeInTheDocument()
})
it('should show updating state when updating tools', () => {
mockIsUpdating = true
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.updateTools')).toBeInTheDocument()
})
it('should show authorizing button when authorizing', () => {
mockIsAuthorizing = true
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Multiple elements show authorizing text - use getAllByText
const authorizingElements = screen.getAllByText('tools.mcp.authorizing')
expect(authorizingElements.length).toBeGreaterThan(0)
})
})
describe('Authorize Flow', () => {
it('should call authorizeMcp when authorize button is clicked', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' })
})
})
it('should open OAuth popup when authorization_url is returned', async () => {
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
'https://oauth.example.com',
expect.any(Function),
)
})
})
it('should trigger authorize on mount when isTriggerAuthorize is true', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} isTriggerAuthorize={true} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalled()
})
})
it('should disable authorize button when not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
expect(authorizeBtn.closest('button')).toBeDisabled()
})
})
describe('Update Tools Flow', () => {
it('should show update confirm dialog when update button is clicked', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should call updateTools when update is confirmed', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// Open confirm dialog
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm the update
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
expect(onUpdate).toHaveBeenCalled()
})
})
it('should call handleUpdateTools when get tools button is clicked', async () => {
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
const getToolsBtn = screen.getByText('tools.mcp.getTools')
fireEvent.click(getToolsBtn)
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
})
})
})
describe('Update MCP Modal', () => {
it('should open update modal when edit button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
})
it('should close update modal when close button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Close modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-update-modal')).not.toBeInTheDocument()
})
})
it('should call updateMCP when form is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Confirm form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: 'mcp-1',
})
expect(onUpdate).toHaveBeenCalled()
})
})
it('should not call onUpdate when updateMCP fails', async () => {
mockUpdateMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Confirm form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Delete MCP Flow', () => {
it('should open delete confirm when remove button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close delete confirm when cancel is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deleteMCP when delete is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
expect(onUpdate).toHaveBeenCalledWith(true)
})
})
it('should not call onUpdate when deleteMCP fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Close Button', () => {
it('should call onHide when close button is clicked', () => {
const onHide = vi.fn()
render(<MCPDetailContent {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
// Find the close button (ActionButton with RiCloseLine)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn =>
btn.querySelector('svg.h-4.w-4'),
)
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
})
describe('Copy Server Identifier', () => {
it('should copy server identifier when clicked', async () => {
const { default: copy } = await import('copy-to-clipboard')
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Find the server identifier element
const serverIdentifier = screen.getByText('test-mcp')
fireEvent.click(serverIdentifier)
expect(copy).toHaveBeenCalledWith('test-mcp')
})
})
describe('OAuth Callback', () => {
it('should call handleUpdateTools on OAuth callback when authorized', async () => {
// Simulate OAuth flow with authorization_url
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// Click authorize to trigger OAuth popup
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalled()
})
// Get the callback function and call it
const oauthCallback = mockOpenOAuthPopup.mock.calls[0][1]
oauthCallback()
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
})
})
it('should not call handleUpdateTools if not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
// OAuth callback should not trigger update for non-manager
// The button is disabled, so we simulate a scenario where OAuth was already started
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Button should be disabled
const authorizeBtn = screen.getByText('tools.mcp.authorize')
expect(authorizeBtn.closest('button')).toBeDisabled()
})
})
describe('Authorized Button', () => {
it('should show authorized button when team is authorized', () => {
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
it('should call handleAuthorize when authorized button is clicked', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
const authorizedBtn = screen.getByText('tools.auth.authorized')
fireEvent.click(authorizedBtn)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalled()
})
})
it('should disable authorized button when not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizedBtn = screen.getByText('tools.auth.authorized')
expect(authorizedBtn.closest('button')).toBeDisabled()
})
})
describe('Cancel Update Confirm', () => {
it('should close update confirm when cancel is clicked', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Open confirm dialog
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel the update
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,71 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ListLoading from './list-loading'
describe('ListLoading', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<ListLoading />)
expect(container).toBeInTheDocument()
})
it('should render 5 skeleton items', () => {
render(<ListLoading />)
const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]')
expect(skeletonItems.length).toBe(5)
})
it('should have rounded-xl class on skeleton items', () => {
render(<ListLoading />)
const skeletonItems = document.querySelectorAll('.rounded-xl')
expect(skeletonItems.length).toBeGreaterThanOrEqual(5)
})
it('should have proper spacing', () => {
render(<ListLoading />)
const container = document.querySelector('.space-y-2')
expect(container).toBeInTheDocument()
})
it('should render placeholder bars with different widths', () => {
render(<ListLoading />)
const bar180 = document.querySelector('.w-\\[180px\\]')
const bar148 = document.querySelector('.w-\\[148px\\]')
const bar196 = document.querySelector('.w-\\[196px\\]')
expect(bar180).toBeInTheDocument()
expect(bar148).toBeInTheDocument()
expect(bar196).toBeInTheDocument()
})
it('should have opacity styling on skeleton bars', () => {
render(<ListLoading />)
const opacity20Bars = document.querySelectorAll('.opacity-20')
const opacity10Bars = document.querySelectorAll('.opacity-10')
expect(opacity20Bars.length).toBeGreaterThan(0)
expect(opacity10Bars.length).toBeGreaterThan(0)
})
})
describe('Structure', () => {
it('should have correct nested structure', () => {
render(<ListLoading />)
const items = document.querySelectorAll('.space-y-3')
expect(items.length).toBe(5)
})
it('should render padding on skeleton items', () => {
render(<ListLoading />)
const paddedItems = document.querySelectorAll('.p-4')
expect(paddedItems.length).toBe(5)
})
it('should render height-2 skeleton bars', () => {
render(<ListLoading />)
const h2Bars = document.querySelectorAll('.h-2')
// 3 bars per skeleton item * 5 items = 15
expect(h2Bars.length).toBe(15)
})
})
})

View File

@ -0,0 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationDropdown from './operation-dropdown'
describe('OperationDropdown', () => {
const defaultProps = {
onEdit: vi.fn(),
onRemove: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationDropdown {...defaultProps} />)
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should render trigger button with more icon', () => {
render(<OperationDropdown {...defaultProps} />)
const button = document.querySelector('button')
expect(button).toBeInTheDocument()
const svg = button?.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render medium size by default', () => {
render(<OperationDropdown {...defaultProps} />)
const icon = document.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should render large size when inCard is true', () => {
render(<OperationDropdown {...defaultProps} inCard={true} />)
const icon = document.querySelector('.h-5.w-5')
expect(icon).toBeInTheDocument()
})
})
describe('Dropdown Behavior', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
// Dropdown content should be rendered
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
}
})
it('should call onOpenChange when opened', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
expect(onOpenChange).toHaveBeenCalledWith(true)
}
})
it('should close dropdown when trigger is clicked again', async () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
fireEvent.click(trigger)
expect(onOpenChange).toHaveBeenLastCalledWith(false)
}
})
})
describe('Menu Actions', () => {
it('should call onEdit when edit option is clicked', () => {
const onEdit = vi.fn()
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const editOption = screen.getByText('tools.mcp.operation.edit')
fireEvent.click(editOption)
expect(onEdit).toHaveBeenCalledTimes(1)
}
})
it('should call onRemove when remove option is clicked', () => {
const onRemove = vi.fn()
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const removeOption = screen.getByText('tools.mcp.operation.remove')
fireEvent.click(removeOption)
expect(onRemove).toHaveBeenCalledTimes(1)
}
})
it('should close dropdown after edit is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
onOpenChange.mockClear()
const editOption = screen.getByText('tools.mcp.operation.edit')
fireEvent.click(editOption)
expect(onOpenChange).toHaveBeenCalledWith(false)
}
})
it('should close dropdown after remove is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
onOpenChange.mockClear()
const removeOption = screen.getByText('tools.mcp.operation.remove')
fireEvent.click(removeOption)
expect(onOpenChange).toHaveBeenCalledWith(false)
}
})
})
describe('Styling', () => {
it('should have correct dropdown width', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const dropdown = document.querySelector('.w-\\[160px\\]')
expect(dropdown).toBeInTheDocument()
}
})
it('should have rounded-xl on dropdown', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(dropdown).toBeInTheDocument()
}
})
it('should show destructive hover style on remove option', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
// The text is in a div, and the hover style is on the parent div with group class
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
const removeOptionContainer = removeOptionText.closest('.group')
expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover')
}
})
})
describe('inCard prop', () => {
it('should adjust offset when inCard is false', () => {
render(<OperationDropdown {...defaultProps} inCard={false} />)
// Component renders with different offset values
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should adjust offset when inCard is true', () => {
render(<OperationDropdown {...defaultProps} inCard={true} />)
// Component renders with different offset values
expect(document.querySelector('button')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,153 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPDetailPanel from './provider-detail'
// Mock the drawer component
vi.mock('@/app/components/base/drawer', () => ({
default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => {
if (!isOpen)
return null
return <div data-testid="drawer">{children}</div>
},
}))
// Mock the content component to expose onUpdate callback
vi.mock('./content', () => ({
default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => (
<div data-testid="mcp-detail-content">
{detail.name}
<button data-testid="update-btn" onClick={() => onUpdate()}>Update</button>
<button data-testid="delete-btn" onClick={() => onUpdate(true)}>Delete</button>
</div>
),
}))
describe('MCPDetailPanel', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockDetail = (): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP',
server_identifier: 'test-mcp',
server_url: 'https://example.com/mcp',
icon: { content: '🔧', background: '#FF0000' },
tools: [],
is_team_authorization: true,
} as unknown as ToolWithProvider)
const defaultProps = {
onUpdate: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
}
describe('Rendering', () => {
it('should render nothing when detail is undefined', () => {
const { container } = render(
<MCPDetailPanel {...defaultProps} detail={undefined} />,
{ wrapper: createWrapper() },
)
expect(container.innerHTML).toBe('')
})
it('should render drawer when detail is provided', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
it('should render content when detail is provided', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
it('should pass detail to content component', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP')).toBeInTheDocument()
})
})
describe('Callbacks', () => {
it('should call onUpdate when update is triggered', () => {
const onUpdate = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// The update callback is passed to content component
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
it('should accept isTriggerAuthorize prop', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
})
describe('handleUpdate', () => {
it('should call onUpdate but not onHide when isDelete is false (default)', () => {
const onUpdate = vi.fn()
const onHide = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Click update button which calls onUpdate() without isDelete parameter
const updateBtn = screen.getByTestId('update-btn')
fireEvent.click(updateBtn)
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onHide).not.toHaveBeenCalled()
})
it('should call both onHide and onUpdate when isDelete is true', () => {
const onUpdate = vi.fn()
const onHide = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Click delete button which calls onUpdate(true)
const deleteBtn = screen.getByTestId('delete-btn')
fireEvent.click(deleteBtn)
expect(onHide).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,126 @@
import type { Tool } from '@/app/components/tools/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import MCPToolItem from './tool-item'
describe('MCPToolItem', () => {
const createMockTool = (overrides = {}): Tool => ({
name: 'test-tool',
label: {
en_US: 'Test Tool',
zh_Hans: '测试工具',
},
description: {
en_US: 'A test tool description',
zh_Hans: '测试工具描述',
},
parameters: [],
...overrides,
} as unknown as Tool)
describe('Rendering', () => {
it('should render without crashing', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should display tool label', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should display tool description', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('A test tool description')).toBeInTheDocument()
})
})
describe('With Parameters', () => {
it('should not show parameters section when no parameters', () => {
const tool = createMockTool({ parameters: [] })
render(<MCPToolItem tool={tool} />)
expect(screen.queryByText('tools.mcp.toolItem.parameters')).not.toBeInTheDocument()
})
it('should render with parameters', () => {
const tool = createMockTool({
parameters: [
{
name: 'param1',
type: 'string',
human_description: {
en_US: 'A parameter description',
},
},
],
})
render(<MCPToolItem tool={tool} />)
// Tooltip content is rendered in portal, may not be visible immediately
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have cursor-pointer class', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('.cursor-pointer')
expect(toolElement).toBeInTheDocument()
})
it('should have rounded-xl class', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('.rounded-xl')
expect(toolElement).toBeInTheDocument()
})
it('should have hover styles', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]')
expect(toolElement).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty label', () => {
const tool = createMockTool({
label: { en_US: '', zh_Hans: '' },
})
render(<MCPToolItem tool={tool} />)
// Should render without crashing
expect(document.querySelector('.cursor-pointer')).toBeInTheDocument()
})
it('should handle empty description', () => {
const tool = createMockTool({
description: { en_US: '', zh_Hans: '' },
})
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should handle long description with line clamp', () => {
const longDescription = 'This is a very long description '.repeat(20)
const tool = createMockTool({
description: { en_US: longDescription, zh_Hans: longDescription },
})
render(<MCPToolItem tool={tool} />)
const descElement = document.querySelector('.line-clamp-2')
expect(descElement).toBeInTheDocument()
})
it('should handle special characters in tool name', () => {
const tool = createMockTool({
name: 'special-tool_v2.0',
label: { en_US: 'Special Tool <v2.0>', zh_Hans: '特殊工具' },
})
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Special Tool <v2.0>')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,245 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersInput from './headers-input'
describe('HeadersInput', () => {
const defaultProps = {
headersItems: [],
onChange: vi.fn(),
}
describe('Empty State', () => {
it('should render no headers message when empty', () => {
render(<HeadersInput {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
})
it('should render add header button when empty and not readonly', () => {
render(<HeadersInput {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
it('should not render add header button when empty and readonly', () => {
render(<HeadersInput {...defaultProps} readonly={true} />)
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
})
it('should call onChange with new item when add button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} onChange={onChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
key: '',
value: '',
}),
])
})
})
describe('With Headers', () => {
const headersItems = [
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
{ id: '2', key: 'Content-Type', value: 'application/json' },
]
it('should render header items', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
})
it('should render delete buttons for each item when not readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
// Should have delete buttons for each header
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
expect(deleteButtons.length).toBe(headersItems.length)
})
it('should not render delete buttons when readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
expect(deleteButtons.length).toBe(0)
})
it('should render add button at bottom when not readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
it('should not render add button when readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
})
})
describe('Masked Headers', () => {
const headersItems = [{ id: '1', key: 'Secret', value: '***' }]
it('should show masked headers tip when isMasked is true', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={true} />)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
it('should not show masked headers tip when isMasked is false', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={false} />)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
})
describe('Item Interactions', () => {
const headersItems = [
{ id: '1', key: 'Header1', value: 'Value1' },
]
it('should call onChange when key is changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const keyInput = screen.getByDisplayValue('Header1')
fireEvent.change(keyInput, { target: { value: 'NewHeader' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'NewHeader', value: 'Value1' },
])
})
it('should call onChange when value is changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const valueInput = screen.getByDisplayValue('Value1')
fireEvent.change(valueInput, { target: { value: 'NewValue' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'NewValue' },
])
})
it('should remove item when delete button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const deleteButton = document.querySelector('[class*="text-text-destructive"]')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onChange).toHaveBeenCalledWith([])
}
})
it('should add new item when add button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
expect.objectContaining({ key: '', value: '' }),
])
})
})
describe('Multiple Headers', () => {
const headersItems = [
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '2', key: 'Header2', value: 'Value2' },
{ id: '3', key: 'Header3', value: 'Value3' },
]
it('should render all headers', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('Header1')).toBeInTheDocument()
expect(screen.getByDisplayValue('Header2')).toBeInTheDocument()
expect(screen.getByDisplayValue('Header3')).toBeInTheDocument()
})
it('should update correct item when changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const header2Input = screen.getByDisplayValue('Header2')
fireEvent.change(header2Input, { target: { value: 'UpdatedHeader2' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '2', key: 'UpdatedHeader2', value: 'Value2' },
{ id: '3', key: 'Header3', value: 'Value3' },
])
})
it('should remove correct item when deleted', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
// Find all delete buttons and click the second one
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
const secondDeleteButton = deleteButtons[1]?.closest('button')
if (secondDeleteButton) {
fireEvent.click(secondDeleteButton)
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '3', key: 'Header3', value: 'Value3' },
])
}
})
})
describe('Readonly Mode', () => {
const headersItems = [{ id: '1', key: 'ReadOnly', value: 'Value' }]
it('should make inputs readonly when readonly is true', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
const keyInput = screen.getByDisplayValue('ReadOnly')
const valueInput = screen.getByDisplayValue('Value')
expect(keyInput).toHaveAttribute('readonly')
expect(valueInput).toHaveAttribute('readonly')
})
it('should not make inputs readonly when readonly is false', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={false} />)
const keyInput = screen.getByDisplayValue('ReadOnly')
const valueInput = screen.getByDisplayValue('Value')
expect(keyInput).not.toHaveAttribute('readonly')
expect(valueInput).not.toHaveAttribute('readonly')
})
})
describe('Edge Cases', () => {
it('should handle empty key and value', () => {
const headersItems = [{ id: '1', key: '', value: '' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBe(2)
})
it('should handle special characters in header key', () => {
const headersItems = [{ id: '1', key: 'X-Custom-Header', value: 'value' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('X-Custom-Header')).toBeInTheDocument()
})
it('should handle JSON value', () => {
const headersItems = [{ id: '1', key: 'Data', value: '{"key":"value"}' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('{"key":"value"}')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,500 @@
import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form'
// Mock the API service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn(),
}))
describe('useMCPModalForm', () => {
describe('Utility Functions', () => {
describe('isValidUrl', () => {
it('should return true for valid http URL', () => {
expect(isValidUrl('http://example.com')).toBe(true)
})
it('should return true for valid https URL', () => {
expect(isValidUrl('https://example.com')).toBe(true)
})
it('should return true for URL with path', () => {
expect(isValidUrl('https://example.com/path/to/resource')).toBe(true)
})
it('should return true for URL with query params', () => {
expect(isValidUrl('https://example.com?foo=bar')).toBe(true)
})
it('should return false for invalid URL', () => {
expect(isValidUrl('not-a-url')).toBe(false)
})
it('should return false for ftp URL', () => {
expect(isValidUrl('ftp://example.com')).toBe(false)
})
it('should return false for empty string', () => {
expect(isValidUrl('')).toBe(false)
})
it('should return false for file URL', () => {
expect(isValidUrl('file:///path/to/file')).toBe(false)
})
})
describe('isValidServerID', () => {
it('should return true for lowercase letters', () => {
expect(isValidServerID('myserver')).toBe(true)
})
it('should return true for numbers', () => {
expect(isValidServerID('123')).toBe(true)
})
it('should return true for alphanumeric with hyphens', () => {
expect(isValidServerID('my-server-123')).toBe(true)
})
it('should return true for alphanumeric with underscores', () => {
expect(isValidServerID('my_server_123')).toBe(true)
})
it('should return true for max length (24 chars)', () => {
expect(isValidServerID('abcdefghijklmnopqrstuvwx')).toBe(true)
})
it('should return false for uppercase letters', () => {
expect(isValidServerID('MyServer')).toBe(false)
})
it('should return false for spaces', () => {
expect(isValidServerID('my server')).toBe(false)
})
it('should return false for special characters', () => {
expect(isValidServerID('my@server')).toBe(false)
})
it('should return false for empty string', () => {
expect(isValidServerID('')).toBe(false)
})
it('should return false for string longer than 24 chars', () => {
expect(isValidServerID('abcdefghijklmnopqrstuvwxy')).toBe(false)
})
})
})
describe('Hook Initialization', () => {
describe('Create Mode (no data)', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.isCreate).toBe(true)
expect(result.current.formKey).toBe('create')
expect(result.current.state.url).toBe('')
expect(result.current.state.name).toBe('')
expect(result.current.state.serverIdentifier).toBe('')
expect(result.current.state.timeout).toBe(30)
expect(result.current.state.sseReadTimeout).toBe(300)
expect(result.current.state.headers).toEqual([])
expect(result.current.state.authMethod).toBe(MCPAuthMethod.authentication)
expect(result.current.state.isDynamicRegistration).toBe(true)
expect(result.current.state.clientID).toBe('')
expect(result.current.state.credentials).toBe('')
})
it('should initialize with default emoji icon', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.state.appIcon).toEqual({
type: 'emoji',
icon: '🔗',
background: '#6366F1',
})
})
})
describe('Edit Mode (with data)', () => {
const mockData: ToolWithProvider = {
id: 'test-id-123',
name: 'Test MCP Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🚀', background: '#FF0000' },
configuration: {
timeout: 60,
sse_read_timeout: 600,
},
masked_headers: {
'Authorization': '***',
'X-Custom': 'value',
},
is_dynamic_registration: false,
authentication: {
client_id: 'client-123',
client_secret: 'secret-456',
},
} as unknown as ToolWithProvider
it('should initialize with data values', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.isCreate).toBe(false)
expect(result.current.formKey).toBe('test-id-123')
expect(result.current.state.url).toBe('https://example.com/mcp')
expect(result.current.state.name).toBe('Test MCP Server')
expect(result.current.state.serverIdentifier).toBe('test-server')
expect(result.current.state.timeout).toBe(60)
expect(result.current.state.sseReadTimeout).toBe(600)
expect(result.current.state.isDynamicRegistration).toBe(false)
expect(result.current.state.clientID).toBe('client-123')
expect(result.current.state.credentials).toBe('secret-456')
})
it('should initialize headers from masked_headers', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.headers).toHaveLength(2)
expect(result.current.state.headers[0].key).toBe('Authorization')
expect(result.current.state.headers[0].value).toBe('***')
expect(result.current.state.headers[1].key).toBe('X-Custom')
expect(result.current.state.headers[1].value).toBe('value')
})
it('should initialize emoji icon from data', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.appIcon.type).toBe('emoji')
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀')
expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000')
})
it('should store original server URL and ID', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.originalServerUrl).toBe('https://example.com/mcp')
expect(result.current.originalServerID).toBe('test-server')
})
})
describe('Edit Mode with string icon', () => {
const mockDataWithImageIcon: ToolWithProvider = {
id: 'test-id',
name: 'Test',
icon: 'https://example.com/files/abc123/file-preview/icon.png',
} as unknown as ToolWithProvider
it('should initialize image icon from string URL', () => {
const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon))
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png')
expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123')
})
})
})
describe('Actions', () => {
it('should update url', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setUrl('https://new-url.com')
})
expect(result.current.state.url).toBe('https://new-url.com')
})
it('should update name', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setName('New Server Name')
})
expect(result.current.state.name).toBe('New Server Name')
})
it('should update serverIdentifier', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setServerIdentifier('new-server-id')
})
expect(result.current.state.serverIdentifier).toBe('new-server-id')
})
it('should update timeout', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setTimeout(120)
})
expect(result.current.state.timeout).toBe(120)
})
it('should update sseReadTimeout', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setSseReadTimeout(900)
})
expect(result.current.state.sseReadTimeout).toBe(900)
})
it('should update headers', () => {
const { result } = renderHook(() => useMCPModalForm())
const newHeaders = [{ id: '1', key: 'X-New', value: 'new-value' }]
act(() => {
result.current.actions.setHeaders(newHeaders)
})
expect(result.current.state.headers).toEqual(newHeaders)
})
it('should update authMethod', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setAuthMethod(MCPAuthMethod.headers)
})
expect(result.current.state.authMethod).toBe(MCPAuthMethod.headers)
})
it('should update isDynamicRegistration', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setIsDynamicRegistration(false)
})
expect(result.current.state.isDynamicRegistration).toBe(false)
})
it('should update clientID', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setClientID('new-client-id')
})
expect(result.current.state.clientID).toBe('new-client-id')
})
it('should update credentials', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setCredentials('new-secret')
})
expect(result.current.state.credentials).toBe('new-secret')
})
it('should update appIcon', () => {
const { result } = renderHook(() => useMCPModalForm())
const newIcon = { type: 'emoji' as const, icon: '🎉', background: '#00FF00' }
act(() => {
result.current.actions.setAppIcon(newIcon)
})
expect(result.current.state.appIcon).toEqual(newIcon)
})
it('should toggle showAppIconPicker', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.state.showAppIconPicker).toBe(false)
act(() => {
result.current.actions.setShowAppIconPicker(true)
})
expect(result.current.state.showAppIconPicker).toBe(true)
})
it('should reset icon to default', () => {
const { result } = renderHook(() => useMCPModalForm())
// Change icon first
act(() => {
result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' })
})
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉')
// Reset icon
act(() => {
result.current.actions.resetIcon()
})
expect(result.current.state.appIcon).toEqual({
type: 'emoji',
icon: '🔗',
background: '#6366F1',
})
})
})
describe('handleUrlBlur', () => {
it('should not fetch icon in edit mode (when data is provided)', async () => {
const mockData = {
id: 'test',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com')
})
// In edit mode, handleUrlBlur should return early
expect(result.current.state.isFetchingIcon).toBe(false)
})
it('should not fetch icon for invalid URL', async () => {
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('not-a-valid-url')
})
expect(result.current.state.isFetchingIcon).toBe(false)
})
it('should handle error when icon fetch fails with error code', async () => {
const { uploadRemoteFileInfo } = await import('@/service/common')
const mockError = {
json: vi.fn().mockResolvedValue({ code: 'UPLOAD_ERROR' }),
}
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Should have called console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// isFetchingIcon should be reset to false after error
expect(result.current.state.isFetchingIcon).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should handle error when icon fetch fails without error code', async () => {
const { uploadRemoteFileInfo } = await import('@/service/common')
const mockError = {
json: vi.fn().mockResolvedValue({}),
}
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Should have called console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// isFetchingIcon should be reset to false after error
expect(result.current.state.isFetchingIcon).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should fetch icon successfully for valid URL in create mode', async () => {
vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({
id: 'file123',
name: 'icon.png',
size: 1024,
mime_type: 'image/png',
url: 'https://example.com/files/file123/file-preview/icon.png',
} as unknown as { id: string, name: string, size: number, mime_type: string, url: string })
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Icon should be set to image type
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png')
expect(result.current.state.isFetchingIcon).toBe(false)
})
})
describe('Edge Cases', () => {
// Base mock data with required icon field
const baseMockData = {
id: 'test',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
}
it('should handle undefined configuration', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.timeout).toBe(30)
expect(result.current.state.sseReadTimeout).toBe(300)
})
it('should handle undefined authentication', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.clientID).toBe('')
expect(result.current.state.credentials).toBe('')
})
it('should handle undefined masked_headers', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.headers).toEqual([])
})
it('should handle undefined is_dynamic_registration (defaults to true)', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.isDynamicRegistration).toBe(true)
})
it('should handle string icon URL', () => {
const mockData = {
id: 'test',
name: 'Test',
icon: 'https://example.com/icon.png',
} as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png')
})
})
})

View File

@ -0,0 +1,203 @@
'use client'
import type { HeaderItem } from '../headers-input'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useCallback, useMemo, useRef, useState } from 'react'
import { getDomain } from 'tldts'
import { v4 as uuid } from 'uuid'
import Toast from '@/app/components/base/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { uploadRemoteFileInfo } from '@/service/common'
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider): AppIconSelection => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
}
const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => {
return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))
}
export const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
export const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
export type MCPModalFormState = {
url: string
name: string
appIcon: AppIconSelection
showAppIconPicker: boolean
serverIdentifier: string
timeout: number
sseReadTimeout: number
headers: HeaderItem[]
isFetchingIcon: boolean
authMethod: MCPAuthMethod
isDynamicRegistration: boolean
clientID: string
credentials: string
}
export type MCPModalFormActions = {
setUrl: (url: string) => void
setName: (name: string) => void
setAppIcon: (icon: AppIconSelection) => void
setShowAppIconPicker: (show: boolean) => void
setServerIdentifier: (id: string) => void
setTimeout: (timeout: number) => void
setSseReadTimeout: (timeout: number) => void
setHeaders: (headers: HeaderItem[]) => void
setAuthMethod: (method: string) => void
setIsDynamicRegistration: (value: boolean) => void
setClientID: (id: string) => void
setCredentials: (credentials: string) => void
handleUrlBlur: (url: string) => Promise<void>
resetIcon: () => void
}
/**
* Custom hook for MCP Modal form state management.
*
* Note: This hook uses a `formKey` (data ID or 'create') to reset form state when
* switching between edit and create modes. All useState initializers read from `data`
* directly, and the key change triggers a remount of the consumer component.
*/
export const useMCPModalForm = (data?: ToolWithProvider) => {
const isCreate = !data
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
// Form key for resetting state - changes when data changes
const formKey = useMemo(() => data?.id ?? 'create', [data?.id])
// Form state - initialized from data
const [url, setUrl] = useState(() => data?.server_url || '')
const [name, setName] = useState(() => data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '')
const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300)
const [headers, setHeaders] = useState<HeaderItem[]>(() => getInitialHeaders(data))
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
// Auth state
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
const handleUrlBlur = useCallback(async (urlValue: string) => {
if (data)
return
if (!isValidUrl(urlValue))
return
const domain = getDomain(urlValue)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
if (e instanceof Response) {
try {
const errorData = await e.json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
}
catch {
// Ignore JSON parsing errors
}
}
else if (e instanceof Error) {
errorMessage = e.message
}
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}, [data])
const resetIcon = useCallback(() => {
setAppIcon(getIcon(data))
}, [data])
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
return {
// Key for form reset (use as React key on parent)
formKey,
// Metadata
isCreate,
originalServerUrl,
originalServerID,
appIconRef,
// State
state: {
url,
name,
appIcon,
showAppIconPicker,
serverIdentifier,
timeout,
sseReadTimeout,
headers,
isFetchingIcon,
authMethod,
isDynamicRegistration,
clientID,
credentials,
} satisfies MCPModalFormState,
// Actions
actions: {
setUrl,
setName,
setAppIcon,
setShowAppIconPicker,
setServerIdentifier,
setTimeout: setMcpTimeout,
setSseReadTimeout,
setHeaders,
setAuthMethod: handleAuthMethodChange,
setIsDynamicRegistration,
setClientID,
setCredentials,
handleUrlBlur,
resetIcon,
} satisfies MCPModalFormActions,
}
}

View File

@ -0,0 +1,451 @@
import type { ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import { useMCPServiceCardState } from './use-mcp-service-card'
// Mutable mock data for MCP server detail
let mockMCPServerDetailData: {
id: string
status: string
server_code: string
description: string
parameters: Record<string, unknown>
} | undefined = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
// Mock service hooks
vi.mock('@/service/use-tools', () => ({
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
}),
useRefreshMCPServerCode: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
useMCPServerDetail: () => ({
data: mockMCPServerDetailData,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
// Mock workflow hook
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (appId: string) => ({
data: appId
? {
graph: {
nodes: [
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
],
},
}
: undefined,
}),
}))
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock apps service
vi.mock('@/service/apps', () => ({
fetchAppDetail: vi.fn().mockResolvedValue({
model_config: {
updated_at: '2024-01-01',
user_input_form: [],
},
}),
}))
describe('useMCPServiceCardState', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial<AppSSO> => ({
id: 'app-123',
name: 'Test App',
mode,
api_base_url: 'https://api.example.com/v1',
} as AppDetailResponse & Partial<AppSSO>)
beforeEach(() => {
// Reset mock data to default (published server)
mockMCPServerDetailData = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
})
describe('Initialization', () => {
it('should initialize with correct default values for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.serverPublished).toBe(true)
expect(result.current.serverActivated).toBe(true)
expect(result.current.showConfirmDelete).toBe(false)
expect(result.current.showMCPServerModal).toBe(false)
})
it('should initialize with correct values for workflow app', () => {
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isLoading).toBe(false)
})
it('should initialize with correct values for advanced chat app', () => {
const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isLoading).toBe(false)
})
})
describe('Server URL Generation', () => {
it('should generate correct server URL when published', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp')
})
})
describe('Permission Flags', () => {
it('should have isCurrentWorkspaceManager as true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isCurrentWorkspaceManager).toBe(true)
})
it('should have toggleDisabled false when editor has permissions', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Toggle is not disabled when user has permissions and app is published
expect(typeof result.current.toggleDisabled).toBe('boolean')
})
it('should have toggleDisabled true when triggerModeDisabled is true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, true),
{ wrapper: createWrapper() },
)
expect(result.current.toggleDisabled).toBe(true)
})
})
describe('UI State Actions', () => {
it('should open confirm delete modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.showConfirmDelete).toBe(false)
act(() => {
result.current.openConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(true)
})
it('should close confirm delete modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
act(() => {
result.current.openConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(true)
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(false)
})
it('should open server modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.showMCPServerModal).toBe(false)
act(() => {
result.current.openServerModal()
})
expect(result.current.showMCPServerModal).toBe(true)
})
it('should handle server modal hide', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
act(() => {
result.current.openServerModal()
})
expect(result.current.showMCPServerModal).toBe(true)
let hideResult: { shouldDeactivate: boolean } | undefined
act(() => {
hideResult = result.current.handleServerModalHide(false)
})
expect(result.current.showMCPServerModal).toBe(false)
expect(hideResult?.shouldDeactivate).toBe(true)
})
it('should not deactivate when wasActivated is true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let hideResult: { shouldDeactivate: boolean } | undefined
act(() => {
hideResult = result.current.handleServerModalHide(true)
})
expect(hideResult?.shouldDeactivate).toBe(false)
})
})
describe('Handler Functions', () => {
it('should have handleGenCode function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.handleGenCode).toBe('function')
})
it('should call handleGenCode and invalidate server detail', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleGenCode()
})
// handleGenCode should complete without error
expect(result.current.genLoading).toBe(false)
})
it('should have handleStatusChange function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.handleStatusChange).toBe('function')
})
it('should have invalidateBasicAppConfig function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
})
it('should call invalidateBasicAppConfig', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Call the function - should not throw
act(() => {
result.current.invalidateBasicAppConfig()
})
// Function should exist and be callable
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
})
})
describe('Status Change', () => {
it('should return activated state when status change succeeds', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
expect(statusResult?.activated).toBe(true)
})
it('should return deactivated state when disabling', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(false)
})
expect(statusResult?.activated).toBe(false)
})
})
describe('Unpublished Server', () => {
it('should open modal and return not activated when enabling unpublished server', async () => {
// Set mock to return undefined (unpublished server)
mockMCPServerDetailData = undefined
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Verify server is not published
expect(result.current.serverPublished).toBe(false)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
// Should open modal and return not activated
expect(result.current.showMCPServerModal).toBe(true)
expect(statusResult?.activated).toBe(false)
})
})
describe('Loading States', () => {
it('should have genLoading state', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.genLoading).toBe('boolean')
})
it('should have isLoading state for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Basic app doesn't need workflow, so isLoading should be false
expect(result.current.isLoading).toBe(false)
})
})
describe('Detail Data', () => {
it('should return detail data when available', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.detail).toBeDefined()
expect(result.current.detail?.id).toBe('server-123')
expect(result.current.detail?.status).toBe('active')
})
})
describe('Latest Params', () => {
it('should return latestParams for workflow app', () => {
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(Array.isArray(result.current.latestParams)).toBe(true)
})
it('should return latestParams for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(Array.isArray(result.current.latestParams)).toBe(true)
})
})
})

View File

@ -0,0 +1,179 @@
'use client'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
const BASIC_APP_CONFIG_KEY = 'basicAppConfig'
type AppInfo = AppDetailResponse & Partial<AppSSO>
type BasicAppConfig = {
updated_at?: string
user_input_form?: Array<Record<string, unknown>>
}
export const useMCPServiceCardState = (
appInfo: AppInfo,
triggerModeDisabled: boolean,
) => {
const appId = appInfo.id
const queryClient = useQueryClient()
// API hooks
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
// Context
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
// UI state
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
// Derived app type values
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
// Workflow data for advanced apps
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
// Basic app config fetch using React Query
const { data: basicAppConfig = {} } = useQuery<BasicAppConfig>({
queryKey: [BASIC_APP_CONFIG_KEY, appId],
queryFn: async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
return (res?.model_config as BasicAppConfig) || {}
},
enabled: isBasicApp && !!appId,
})
// MCP server detail
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
// Server state
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished
? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp`
: '***********'
// App state checks
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
// Basic app input form
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
return []
return (basicAppConfig.user_input_form as Array<Record<string, unknown>>).map((item) => {
const type = Object.keys(item)[0]
return {
...(item[type] as object),
type: type || 'text-input',
}
})
}, [basicAppConfig?.user_input_form, isBasicApp])
// Latest params for modal
const latestParams = useMemo(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> }
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined
return startNode?.data.variables || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
// Handlers
const handleGenCode = useCallback(async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}, [refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId])
const handleStatusChange = useCallback(async (state: boolean) => {
if (state && !serverPublished) {
setShowMCPServerModal(true)
return { activated: false }
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: state ? 'active' : 'inactive',
})
invalidateMCPServerDetail(appId)
return { activated: state }
}, [serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail])
const handleServerModalHide = useCallback((wasActivated: boolean) => {
setShowMCPServerModal(false)
// If server wasn't activated before opening modal, keep it deactivated
return { shouldDeactivate: !wasActivated }
}, [])
const openConfirmDelete = useCallback(() => setShowConfirmDelete(true), [])
const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), [])
const openServerModal = useCallback(() => setShowMCPServerModal(true), [])
const invalidateBasicAppConfig = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] })
}, [queryClient, appId])
return {
// Loading states
genLoading,
isLoading: isAdvancedApp ? !currentWorkflow : false,
// Server state
serverPublished,
serverActivated,
serverURL,
detail,
// Permission & validation flags
isCurrentWorkspaceManager,
toggleDisabled,
isMinimalState,
appUnpublished,
missingStartNode,
// UI state
showConfirmDelete,
showMCPServerModal,
// Data
latestParams,
// Handlers
handleGenCode,
handleStatusChange,
handleServerModalHide,
openConfirmDelete,
closeConfirmDelete,
openServerModal,
invalidateBasicAppConfig,
}
}

View File

@ -0,0 +1,361 @@
import type { ReactNode } from 'react'
import type { MCPServerDetail } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerModal from './mcp-server-modal'
// Mock the services
vi.mock('@/service/use-tools', () => ({
useCreateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
isPending: false,
}),
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
isPending: false,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
describe('MCPServerModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
appID: 'app-123',
show: true,
onHide: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
})
it('should render add title when no data is provided', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
})
it('should render edit title when data is provided', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: {},
} as unknown as MCPServerDetail
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.editTitle')).toBeInTheDocument()
})
it('should render description label', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.description')).toBeInTheDocument()
})
it('should render required indicator', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('*')).toBeInTheDocument()
})
it('should render description textarea', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
})
it('should render confirm button in add mode', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.confirm')).toBeInTheDocument()
})
it('should render save button in edit mode', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: {},
} as unknown as MCPServerDetail
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
})
it('should render close icon', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const closeButton = document.querySelector('.cursor-pointer svg')
expect(closeButton).toBeInTheDocument()
})
})
describe('Parameters Section', () => {
it('should not render parameters section when no latestParams', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.server.modal.parameters')).not.toBeInTheDocument()
})
it('should render parameters section when latestParams is provided', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.parameters')).toBeInTheDocument()
})
it('should render parameters tip', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.parametersTip')).toBeInTheDocument()
})
it('should render parameter items', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
{ variable: 'param2', label: 'Parameter 2', type: 'number' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
expect(screen.getByText('Parameter 2')).toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update description when typing', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(textarea).toHaveValue('New description')
})
it('should call onHide when cancel button is clicked', () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const closeButton = document.querySelector('.cursor-pointer')
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
it('should disable confirm button when description is empty', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when description is filled', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Valid description' } })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).not.toBeDisabled()
})
})
describe('Edit Mode', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: { param1: 'existing value' },
} as unknown as MCPServerDetail
it('should populate description with existing value', () => {
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('Existing description')
})
it('should populate parameters with existing values', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} data={mockData} latestParams={latestParams} />,
{ wrapper: createWrapper() },
)
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(paramInput).toHaveValue('existing value')
})
})
describe('Form Submission', () => {
it('should submit form with description', async () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
})
describe('With App Info', () => {
it('should use appInfo description as default when no data', () => {
const appInfo = { description: 'App default description' }
render(<MCPServerModal {...defaultProps} appInfo={appInfo} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('App default description')
})
it('should prefer data description over appInfo description', () => {
const appInfo = { description: 'App default description' }
const mockData = {
id: 'server-1',
description: 'Data description',
parameters: {},
} as unknown as MCPServerDetail
render(
<MCPServerModal {...defaultProps} data={mockData} appInfo={appInfo} />,
{ wrapper: createWrapper() },
)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('Data description')
})
})
describe('Not Shown State', () => {
it('should not render modal content when show is false', () => {
render(<MCPServerModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.server.modal.addTitle')).not.toBeInTheDocument()
})
})
describe('Update Mode Submission', () => {
it('should submit update when data is provided', async () => {
const onHide = vi.fn()
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: { param1: 'value1' },
} as unknown as MCPServerDetail
render(
<MCPServerModal {...defaultProps} data={mockData} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Change description
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Updated description' } })
// Click save button
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
})
describe('Parameter Handling', () => {
it('should update parameter value when changed', async () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
{ variable: 'param2', label: 'Parameter 2', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} latestParams={latestParams} />,
{ wrapper: createWrapper() },
)
// Fill description first
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
// Get all parameter inputs
const paramInputs = screen.getAllByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
// Change the first parameter value
fireEvent.change(paramInputs[0], { target: { value: 'new param value' } })
expect(paramInputs[0]).toHaveValue('new param value')
})
it('should submit with parameter values', async () => {
const onHide = vi.fn()
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Fill description
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
// Fill parameter
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(paramInput, { target: { value: 'param value' } })
// Submit
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
it('should handle empty description submission', async () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: '' } })
// Button should be disabled
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).toBeDisabled()
})
})
})

View File

@ -0,0 +1,165 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerParamItem from './mcp-server-param-item'
describe('MCPServerParamItem', () => {
const defaultProps = {
data: {
label: 'Test Label',
variable: 'test_variable',
type: 'string',
},
value: '',
onChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should display label', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should display variable name', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('test_variable')).toBeInTheDocument()
})
it('should display type', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('string')).toBeInTheDocument()
})
it('should display separator dot', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render textarea with placeholder', () => {
render(<MCPServerParamItem {...defaultProps} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toBeInTheDocument()
})
})
describe('Value Display', () => {
it('should display empty value by default', () => {
render(<MCPServerParamItem {...defaultProps} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue('')
})
it('should display provided value', () => {
render(<MCPServerParamItem {...defaultProps} value="test value" />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue('test value')
})
it('should display long text value', () => {
const longValue = 'This is a very long text value that might span multiple lines'
render(<MCPServerParamItem {...defaultProps} value={longValue} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue(longValue)
})
})
describe('User Interactions', () => {
it('should call onChange when text is entered', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: 'new value' } })
expect(onChange).toHaveBeenCalledWith('new value')
})
it('should call onChange with empty string when cleared', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} value="existing" onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should handle multiple changes', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: 'first' } })
fireEvent.change(textarea, { target: { value: 'second' } })
fireEvent.change(textarea, { target: { value: 'third' } })
expect(onChange).toHaveBeenCalledTimes(3)
expect(onChange).toHaveBeenLastCalledWith('third')
})
})
describe('Different Data Types', () => {
it('should display number type', () => {
const props = {
...defaultProps,
data: { label: 'Count', variable: 'count', type: 'number' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('number')).toBeInTheDocument()
})
it('should display boolean type', () => {
const props = {
...defaultProps,
data: { label: 'Enabled', variable: 'enabled', type: 'boolean' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display array type', () => {
const props = {
...defaultProps,
data: { label: 'Items', variable: 'items', type: 'array' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('array')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle special characters in label', () => {
const props = {
...defaultProps,
data: { label: 'Test <Label> & "Special"', variable: 'test', type: 'string' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('Test <Label> & "Special"')).toBeInTheDocument()
})
it('should handle empty data object properties', () => {
const props = {
...defaultProps,
data: { label: '', variable: '', type: '' },
}
render(<MCPServerParamItem {...props} />)
// Should render without crashing
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should handle unicode characters in value', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: '你好世界 🌍' } })
expect(onChange).toHaveBeenCalledWith('你好世界 🌍')
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1,168 +1,234 @@
'use client'
import type { TFunction } from 'i18next'
import type { FC, ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import {
Mcp,
} from '@/app/components/base/icons/src/vender/other'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
// Sub-components
type StatusIndicatorProps = {
serverActivated: boolean
}
function MCPServiceCard({
const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
)
}
type ServerURLSectionProps = {
serverURL: string
serverPublished: boolean
isCurrentWorkspaceManager: boolean
genLoading: boolean
onRegenerate: () => void
}
const ServerURLSection: FC<ServerURLSectionProps> = ({
serverURL,
serverPublished,
isCurrentWorkspaceManager,
genLoading,
onRegenerate,
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('mcp.server.url', { ns: 'tools' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback content={serverURL} className="!size-6" />
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={onRegenerate}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
)
}
type TriggerModeOverlayProps = {
triggerModeMessage: ReactNode
}
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
if (triggerModeMessage) {
return (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
}
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
}
// Helper function for tooltip content
type TooltipContentParams = {
toggleDisabled: boolean
appUnpublished: boolean
missingStartNode: boolean
triggerModeMessage: ReactNode
t: TFunction
docLink: ReturnType<typeof useDocLink>
}
function getTooltipContent({
toggleDisabled,
appUnpublished,
missingStartNode,
triggerModeMessage,
t,
docLink,
}: TooltipContentParams): ReactNode {
if (!toggleDisabled)
return ''
if (appUnpublished)
return t('mcp.server.publishTip', { ns: 'tools' })
if (missingStartNode) {
return (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
}
return triggerModeMessage || ''
}
// Main component
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean
triggerModeMessage?: ReactNode
}
const MCPServiceCard: FC<IAppCardProps> = ({
appInfo,
triggerModeDisabled = false,
triggerModeMessage = '',
}: IAppCardProps) {
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const appId = appInfo.id
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
if (isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
const {
genLoading,
isLoading,
serverPublished,
serverActivated,
serverURL,
detail,
isCurrentWorkspaceManager,
toggleDisabled,
isMinimalState,
appUnpublished,
missingStartNode,
showConfirmDelete,
showMCPServerModal,
latestParams,
handleGenCode,
handleStatusChange,
handleServerModalHide,
openConfirmDelete,
closeConfirmDelete,
openServerModal,
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
const onGenCode = async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}
// Pending status for optimistic updates (null means use server state)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const activated = pendingStatus ?? serverActivated
const onChangeStatus = async (state: boolean) => {
setActivated(state)
if (state) {
if (!serverPublished) {
setShowMCPServerModal(true)
return
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'active',
})
invalidateMCPServerDetail(appId)
}
else {
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'inactive',
})
invalidateMCPServerDetail(appId)
setPendingStatus(state)
const result = await handleStatusChange(state)
if (!result.activated && state) {
// Server modal was opened instead, clear pending status
setPendingStatus(null)
}
}
const handleServerModalHide = () => {
setShowMCPServerModal(false)
if (!serverActivated)
setActivated(false)
const onServerModalHide = () => {
handleServerModalHide(serverActivated)
// Clear pending status when modal closes to sync with server state
setPendingStatus(null)
}
useEffect(() => {
setActivated(serverActivated)
}, [serverActivated])
const onConfirmRegenerate = () => {
handleGenCode()
closeConfirmDelete()
}
if (!currentWorkflow && isAdvancedApp)
if (isLoading)
return null
const tooltipContent = getTooltipContent({
toggleDisabled,
appUnpublished,
missingStartNode,
triggerModeMessage,
t,
docLink,
})
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
{triggerModeDisabled && (
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
<TriggerModeOverlay triggerModeMessage={triggerModeMessage} />
)}
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className="flex w-full items-center gap-3 self-stretch">
@ -176,40 +242,9 @@ function MCPServiceCard({
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
<StatusIndicator serverActivated={serverActivated} />
<Tooltip
popupContent={
toggleDisabled
? (
appUnpublished
? (
t('mcp.server.publishTip', { ns: 'tools' })
)
: missingStartNode
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: triggerModeMessage || ''
)
: ''
}
popupContent={tooltipContent}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
@ -220,39 +255,13 @@ function MCPServiceCard({
</Tooltip>
</div>
{!isMinimalState && (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('mcp.server.url', { ns: 'tools' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback
content={serverURL}
className="!size-6"
/>
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip
popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={() => setShowConfirmDelete(true)}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
<ServerURLSection
serverURL={serverURL}
serverPublished={serverPublished}
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
genLoading={genLoading}
onRegenerate={openConfirmDelete}
/>
)}
</div>
{!isMinimalState && (
@ -261,40 +270,39 @@ function MCPServiceCard({
disabled={toggleDisabled}
size="small"
variant="ghost"
onClick={() => setShowMCPServerModal(true)}
onClick={openServerModal}
>
<div className="flex items-center justify-center gap-[1px]">
<RiEditLine className="h-3.5 w-3.5" />
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}</div>
<div className="system-xs-medium px-[3px] text-text-tertiary">
{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}
</div>
</div>
</Button>
</div>
)}
</div>
</div>
{showMCPServerModal && (
<MCPServerModal
show={showMCPServerModal}
appID={appId}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={handleServerModalHide}
onHide={onServerModalHide}
appInfo={appInfo}
/>
)}
{/* button copy link/ button regenerate */}
{showConfirmDelete && (
<Confirm
type="warning"
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
content={t('mcp.server.reGen', { ns: 'tools' })}
isShow={showConfirmDelete}
onConfirm={() => {
onGenCode()
setShowConfirmDelete(false)
}}
onCancel={() => setShowConfirmDelete(false)}
onConfirm={onConfirmRegenerate}
onCancel={closeConfirmDelete}
/>
)}
</>

View File

@ -0,0 +1,745 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPModal from './modal'
// Mock the service API
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock the AppIconPicker component
type IconPayload = {
type: string
icon: string
background: string
}
type AppIconPickerProps = {
onSelect: (payload: IconPayload) => void
onClose: () => void
}
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: AppIconPickerProps) => (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji-btn" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#FF0000' })}>
Select Emoji
</button>
<button data-testid="close-picker-btn" onClick={onClose}>
Close Picker
</button>
</div>
),
}))
// Mock the plugins service to avoid React Query issues from TabSlider
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
describe('MCPModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
show: true,
onConfirm: vi.fn(),
onHide: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
it('should not render when show is false', () => {
render(<MCPModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.modal.title')).not.toBeInTheDocument()
})
it('should render create title when no data is provided', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
it('should render edit title when data is provided', () => {
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.editTitle')).toBeInTheDocument()
})
})
describe('Form Fields', () => {
it('should render server URL input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.serverUrl')).toBeInTheDocument()
})
it('should render name input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.name')).toBeInTheDocument()
})
it('should render server identifier input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.serverIdentifier')).toBeInTheDocument()
})
it('should render auth method tabs', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.authentication')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.configurations')).toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update URL input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://test.com/mcp' } })
expect(urlInput).toHaveValue('https://test.com/mcp')
})
it('should update name input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My Server' } })
expect(nameInput).toHaveValue('My Server')
})
it('should update server identifier input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(identifierInput, { target: { value: 'my-server' } })
expect(identifierInput).toHaveValue('my-server')
})
})
describe('Tab Navigation', () => {
it('should show authentication section by default', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
})
it('should switch to headers section when clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
})
it('should switch to configurations section when clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const configTab = screen.getByText('tools.mcp.modal.configurations')
fireEvent.click(configTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
})
})
})
describe('Action Buttons', () => {
it('should render confirm button', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.confirm')).toBeInTheDocument()
})
it('should render save button in edit mode', () => {
const mockData = {
id: 'test-id',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
})
it('should call onHide when cancel is clicked', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
// Find the close button by its parent div with cursor-pointer class
const closeButtons = document.querySelectorAll('.cursor-pointer')
const closeButton = Array.from(closeButtons).find(el =>
el.querySelector('svg'),
)
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
it('should have confirm button disabled when form is empty', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when required fields are filled', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
expect(confirmButton).not.toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call onConfirm with correct data when form is submitted', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
}),
)
})
})
it('should not call onConfirm with invalid URL', async () => {
const onConfirm = vi.fn()
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill fields with invalid URL
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'not-a-valid-url' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
})
it('should not call onConfirm with invalid server identifier', async () => {
const onConfirm = vi.fn()
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill fields with invalid server identifier
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'Invalid Server ID!' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
})
})
describe('Edit Mode', () => {
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
configuration: {
timeout: 60,
sse_read_timeout: 600,
},
masked_headers: {
Authorization: '***',
},
is_dynamic_registration: false,
authentication: {
client_id: 'client-123',
client_secret: 'secret-456',
},
} as unknown as ToolWithProvider
it('should populate form with existing data', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByDisplayValue('https://existing.com/mcp')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing-server')).toBeInTheDocument()
})
it('should show warning when URL is changed', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
expect(screen.getByText('tools.mcp.modal.serverUrlWarning')).toBeInTheDocument()
})
it('should show warning when server identifier is changed', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const identifierInput = screen.getByDisplayValue('existing-server')
fireEvent.change(identifierInput, { target: { value: 'new-server' } })
expect(screen.getByText('tools.mcp.modal.serverIdentifierWarning')).toBeInTheDocument()
})
})
describe('Form Key Reset', () => {
it('should reset form when switching from create to edit mode', () => {
const { rerender } = render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Fill some data in create mode
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
fireEvent.change(nameInput, { target: { value: 'New Server' } })
// Switch to edit mode with different data
const mockData = {
id: 'edit-id',
name: 'Edit Server',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
rerender(<MCPModal {...defaultProps} data={mockData} />)
// Should show edit mode data
expect(screen.getByDisplayValue('Edit Server')).toBeInTheDocument()
})
})
describe('URL Blur Handler', () => {
it('should trigger URL blur handler when URL input loses focus', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: ' https://test.com/mcp ' } })
fireEvent.blur(urlInput)
// The blur handler trims the value
expect(urlInput).toHaveValue(' https://test.com/mcp ')
})
it('should handle URL blur with empty value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: '' } })
fireEvent.blur(urlInput)
expect(urlInput).toHaveValue('')
})
})
describe('App Icon', () => {
it('should render app icon with default emoji', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// The app icon should be rendered
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
expect(appIcons.length).toBeGreaterThan(0)
})
it('should render app icon in edit mode with custom icon', () => {
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
// The app icon should be rendered
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
expect(appIcons.length).toBeGreaterThan(0)
})
})
describe('Form Submission with Headers', () => {
it('should submit form with headers data', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Switch to headers tab and add a header
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
}),
)
})
})
it('should submit with authentication data', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Submit form
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
authentication: expect.objectContaining({
client_id: '',
client_secret: '',
}),
}),
)
})
})
it('should format headers correctly when submitting with header keys', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🔗', background: '#6366F1' },
masked_headers: {
'Authorization': 'Bearer token',
'X-Custom': 'value',
},
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Switch to headers tab
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
// Submit form
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: expect.any(String),
}),
}),
)
})
})
})
describe('Edit Mode Submission', () => {
it('should send hidden URL when URL is unchanged in edit mode', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Don't change the URL, just submit
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
server_url: '[__HIDDEN__]',
}),
)
})
})
it('should send new URL when URL is changed in edit mode', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Change the URL
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
server_url: 'https://new.com/mcp',
}),
)
})
})
})
describe('Configuration Section', () => {
it('should submit with default timeout values', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
configuration: expect.objectContaining({
timeout: 30,
sse_read_timeout: 300,
}),
}),
)
})
})
it('should submit with custom timeout values', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Switch to configurations tab
const configTab = screen.getByText('tools.mcp.modal.configurations')
fireEvent.click(configTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
})
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalled()
})
})
})
describe('Dynamic Registration', () => {
it('should toggle dynamic registration', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Find the switch for dynamic registration
const switchElements = screen.getAllByRole('switch')
expect(switchElements.length).toBeGreaterThan(0)
// Click the first switch (dynamic registration)
fireEvent.click(switchElements[0])
// The switch should toggle
expect(switchElements[0]).toBeInTheDocument()
})
})
describe('App Icon Picker Interactions', () => {
it('should open app icon picker when app icon is clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Find the app icon container with cursor-pointer and rounded-2xl classes
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
// The mocked AppIconPicker should now be visible
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
}
})
it('should close app icon picker and update icon when selecting an icon', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Open the icon picker
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
// Click the select emoji button
const selectBtn = screen.getByTestId('select-emoji-btn')
fireEvent.click(selectBtn)
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
}
})
it('should close app icon picker and reset icon when close button is clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Open the icon picker
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
// Click the close button
const closeBtn = screen.getByTestId('close-picker-btn')
fireEvent.click(closeBtn)
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
}
})
})
})

View File

@ -1,429 +1,298 @@
'use client'
import type { HeaderItem } from './headers-input'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getDomain } from 'tldts'
import { v4 as uuid } from 'uuid'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Switch from '@/app/components/base/switch'
import TabSlider from '@/app/components/base/tab-slider'
import Toast from '@/app/components/base/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { API_PREFIX } from '@/config'
import { uploadRemoteFileInfo } from '@/service/common'
import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
import HeadersInput from './headers-input'
import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form'
import AuthenticationSection from './sections/authentication-section'
import ConfigurationsSection from './sections/configurations-section'
import HeadersSection from './sections/headers-section'
export type MCPModalConfirmPayload = {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}
export type DuplicateAppModalProps = {
data?: ToolWithProvider
show: boolean
onConfirm: (info: {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}) => void
onConfirm: (info: MCPModalConfirmPayload) => void
onHide: () => void
}
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider) => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
type MCPModalContentProps = {
data?: ToolWithProvider
onConfirm: (info: MCPModalConfirmPayload) => void
onHide: () => void
}
const MCPModal = ({
const MCPModalContent: FC<MCPModalContentProps> = ({
data,
show,
onConfirm,
onHide,
}: DuplicateAppModalProps) => {
}) => {
const { t } = useTranslation()
const isCreate = !data
const {
isCreate,
originalServerUrl,
originalServerID,
appIconRef,
state,
actions,
} = useMCPModalForm(data)
const isHovering = useHover(appIconRef)
const authMethods = [
{
text: t('mcp.modal.authentication', { ns: 'tools' }),
value: MCPAuthMethod.authentication,
},
{
text: t('mcp.modal.headers', { ns: 'tools' }),
value: MCPAuthMethod.headers,
},
{
text: t('mcp.modal.configurations', { ns: 'tools' }),
value: MCPAuthMethod.configurations,
},
{ text: t('mcp.modal.authentication', { ns: 'tools' }), value: MCPAuthMethod.authentication },
{ text: t('mcp.modal.headers', { ns: 'tools' }), value: MCPAuthMethod.headers },
{ text: t('mcp.modal.configurations', { ns: 'tools' }), value: MCPAuthMethod.configurations },
]
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
const [url, setUrl] = React.useState(data?.server_url || '')
const [name, setName] = React.useState(data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
const [timeout, setMcpTimeout] = React.useState(data?.configuration?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.configuration?.sse_read_timeout || 300)
const [headers, setHeaders] = React.useState<HeaderItem[]>(
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
)
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
const isHovering = useHover(appIconRef)
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration)
const [clientID, setClientID] = useState(data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '')
// Update states when data changes (for edit mode)
React.useEffect(() => {
if (data) {
setUrl(data.server_url || '')
setName(data.name || '')
setServerIdentifier(data.server_identifier || '')
setMcpTimeout(data.configuration?.timeout || 30)
setSseReadTimeout(data.configuration?.sse_read_timeout || 300)
setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })))
setAppIcon(getIcon(data))
setIsDynamicRegistration(data.is_dynamic_registration)
setClientID(data.authentication?.client_id || '')
setCredentials(data.authentication?.client_secret || '')
}
else {
// Reset for create mode
setUrl('')
setName('')
setServerIdentifier('')
setMcpTimeout(30)
setSseReadTimeout(300)
setHeaders([])
setAppIcon(DEFAULT_ICON as AppIconSelection)
setIsDynamicRegistration(true)
setClientID('')
setCredentials('')
}
}, [data])
const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
const handleBlur = async (url: string) => {
if (data)
return
if (!isValidUrl(url))
return
const domain = getDomain(url)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
const errorData = await (e as Response).json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}
const submit = async () => {
if (!isValidUrl(url)) {
if (!isValidUrl(state.url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
return
}
if (!isValidServerID(serverIdentifier.trim())) {
if (!isValidServerID(state.serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
return
}
const formattedHeaders = headers.reduce((acc, item) => {
const formattedHeaders = state.headers.reduce((acc, item) => {
if (item.key.trim())
acc[item.key.trim()] = item.value
return acc
}, {} as Record<string, string>)
await onConfirm({
server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
name,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
server_identifier: serverIdentifier.trim(),
server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(),
name: state.name,
icon_type: state.appIcon.type,
icon: state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId,
icon_background: state.appIcon.type === 'emoji' ? state.appIcon.background : undefined,
server_identifier: state.serverIdentifier.trim(),
headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined,
is_dynamic_registration: isDynamicRegistration,
is_dynamic_registration: state.isDynamicRegistration,
authentication: {
client_id: clientID,
client_secret: credentials,
client_id: state.clientID,
client_secret: state.credentials,
},
configuration: {
timeout: timeout || 30,
sse_read_timeout: sseReadTimeout || 300,
timeout: state.timeout || 30,
sse_read_timeout: state.sseReadTimeout || 300,
},
})
if (isCreate)
onHide()
}
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
const handleIconSelect = (payload: AppIconSelection) => {
actions.setAppIcon(payload)
actions.setShowAppIconPicker(false)
}
const handleIconClose = () => {
actions.resetIcon()
actions.setShowAppIconPicker(false)
}
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
return (
<>
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}</div>
<div className="space-y-5 py-3">
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
</div>
<Input
value={url}
onChange={e => setUrl(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
/>
{originalServerUrl && originalServerUrl !== url && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
</div>
)}
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">
{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
</div>
<div className="space-y-5 py-3">
{/* Server URL */}
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
</div>
<div className="flex space-x-3">
<div className="grow pb-1">
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
/>
</div>
<div className="pt-2" ref={appIconRef}>
<AppIcon
iconType={appIcon.type}
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
size="xxl"
className="relative cursor-pointer rounded-2xl"
coverElement={
isHovering
? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
<RiEditLine className="size-6 text-text-primary-on-surface" />
</div>
)
: null
}
onClick={() => { setShowAppIconPicker(true) }}
/>
</div>
</div>
<div>
<div className="flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
<Input
value={serverIdentifier}
onChange={e => setServerIdentifier(e.target.value)}
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
/>
{originalServerID && originalServerID !== serverIdentifier && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
<TabSlider
className="w-full"
itemClassName={(isActive) => {
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
}}
value={authMethod}
onChange={handleAuthMethodChange}
options={authMethods}
<Input
value={state.url}
onChange={e => actions.setUrl(e.target.value)}
onBlur={e => actions.handleUrlBlur(e.target.value.trim())}
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
/>
{
authMethod === MCPAuthMethod.authentication && (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={isDynamicRegistration}
onChange={setIsDynamicRegistration}
/>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
</div>
{!isDynamicRegistration && (
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-secondary">
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
{originalServerUrl && originalServerUrl !== state.url && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
{/* Name and Icon */}
<div className="flex space-x-3">
<div className="grow pb-1">
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
</div>
<Input
value={state.name}
onChange={e => actions.setName(e.target.value)}
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
/>
</div>
<div className="pt-2" ref={appIconRef}>
<AppIcon
iconType={state.appIcon.type}
icon={state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId}
background={state.appIcon.type === 'emoji' ? state.appIcon.background : undefined}
imageUrl={state.appIcon.type === 'image' ? state.appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(state.appIcon.type, state.appIcon.type === 'emoji' ? state.appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
size="xxl"
className="relative cursor-pointer rounded-2xl"
coverElement={
isHovering
? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
<RiEditLine className="size-6 text-text-primary-on-surface" />
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
</div>
<Input
value={clientID}
onChange={e => setClientID(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
</div>
<Input
value={credentials}
onChange={e => setCredentials(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
{
authMethod === MCPAuthMethod.headers && (
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
<HeadersInput
headersItems={headers}
onChange={setHeaders}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
{
authMethod === MCPAuthMethod.configurations && (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={timeout}
onChange={e => setMcpTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={sseReadTimeout}
onChange={e => setSseReadTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
</>
)
}
)
: null
}
onClick={() => actions.setShowAppIconPicker(true)}
/>
</div>
</div>
<div className="flex flex-row-reverse pt-5">
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}</Button>
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
{/* Server Identifier */}
<div>
<div className="flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
<Input
value={state.serverIdentifier}
onChange={e => actions.setServerIdentifier(e.target.value)}
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
/>
{originalServerID && originalServerID !== state.serverIdentifier && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
</Modal>
{showAppIconPicker && (
{/* Auth Method Tabs */}
<TabSlider
className="w-full"
itemClassName={isActive => `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`}
value={state.authMethod}
onChange={actions.setAuthMethod}
options={authMethods}
/>
{/* Tab Content */}
{state.authMethod === MCPAuthMethod.authentication && (
<AuthenticationSection
isDynamicRegistration={state.isDynamicRegistration}
onDynamicRegistrationChange={actions.setIsDynamicRegistration}
clientID={state.clientID}
onClientIDChange={actions.setClientID}
credentials={state.credentials}
onCredentialsChange={actions.setCredentials}
/>
)}
{state.authMethod === MCPAuthMethod.headers && (
<HeadersSection
headers={state.headers}
onHeadersChange={actions.setHeaders}
isCreate={isCreate}
/>
)}
{state.authMethod === MCPAuthMethod.configurations && (
<ConfigurationsSection
timeout={state.timeout}
onTimeoutChange={actions.setTimeout}
sseReadTimeout={state.sseReadTimeout}
onSseReadTimeoutChange={actions.setSseReadTimeout}
/>
)}
</div>
{/* Actions */}
<div className="flex flex-row-reverse pt-5">
<Button disabled={isSubmitDisabled} className="ml-2" variant="primary" onClick={submit}>
{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}
</Button>
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
</div>
{state.showAppIconPicker && (
<AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(getIcon(data))
setShowAppIconPicker(false)
}}
onSelect={handleIconSelect}
onClose={handleIconClose}
/>
)}
</>
)
}
/**
* MCP Modal component for creating and editing MCP server configurations.
*
* Uses a keyed inner component to ensure form state resets when switching
* between create mode and edit mode with different data.
*/
const MCPModal: FC<DuplicateAppModalProps> = ({
data,
show,
onConfirm,
onHide,
}) => {
// Use data ID as key to reset form state when switching between items
const formKey = data?.id ?? 'create'
return (
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<MCPModalContent
key={formKey}
data={data}
onConfirm={onConfirm}
onHide={onHide}
/>
</Modal>
)
}

View File

@ -0,0 +1,524 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPCard from './provider-card'
// Mutable mock functions
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
// Mock the services
vi.mock('@/service/use-tools', () => ({
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock the MCPModal
type MCPModalForm = {
name: string
server_url: string
}
type MCPModalProps = {
show: boolean
onConfirm: (form: MCPModalForm) => void
onHide: () => void
}
vi.mock('./modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-modal">
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
Confirm
</button>
<button data-testid="modal-close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mock the Confirm dialog
type ConfirmDialogProps = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
isLoading: boolean
}
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, isLoading }: ConfirmDialogProps) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button data-testid="confirm-delete-btn" onClick={onConfirm} disabled={isLoading}>
{isLoading ? 'Deleting...' : 'Confirm Delete'}
</button>
<button data-testid="cancel-delete-btn" onClick={onCancel}>
Cancel
</button>
</div>
)
},
}))
// Mock the OperationDropdown
type OperationDropdownProps = {
onEdit: () => void
onRemove: () => void
onOpenChange: (open: boolean) => void
}
vi.mock('./detail/operation-dropdown', () => ({
default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => (
<div data-testid="operation-dropdown">
<button
data-testid="edit-btn"
onClick={() => {
onOpenChange(true)
onEdit()
}}
>
Edit
</button>
<button
data-testid="remove-btn"
onClick={() => {
onOpenChange(true)
onRemove()
}}
>
Remove
</button>
</div>
),
}))
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the format time hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (_timestamp: number) => '2 hours ago',
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
describe('MCPCard', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockData = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
server_identifier: 'test-server',
icon: { content: '🔧', background: '#FF0000' },
tools: [
{ name: 'tool1', description: 'Tool 1' },
{ name: 'tool2', description: 'Tool 2' },
],
is_team_authorization: true,
updated_at: Date.now() / 1000,
...overrides,
} as unknown as ToolWithProvider)
const defaultProps = {
data: createMockData(),
handleSelect: vi.fn(),
onUpdate: vi.fn(),
onDeleted: vi.fn(),
}
beforeEach(() => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display MCP name', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display server identifier', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-server')).toBeInTheDocument()
})
it('should display tools count', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// The tools count uses i18n with count parameter
expect(screen.getByText(/tools.mcp.toolsCount/)).toBeInTheDocument()
})
it('should display update time', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText(/tools.mcp.updateTime/)).toBeInTheDocument()
})
})
describe('No Tools State', () => {
it('should show no tools message when tools array is empty', () => {
const dataWithNoTools = createMockData({ tools: [] })
render(
<MCPCard {...defaultProps} data={dataWithNoTools} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noTools')).toBeInTheDocument()
})
it('should show not configured badge when not authorized', () => {
const dataNotAuthorized = createMockData({ is_team_authorization: false })
render(
<MCPCard {...defaultProps} data={dataNotAuthorized} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
it('should show not configured badge when no tools', () => {
const dataWithNoTools = createMockData({ tools: [], is_team_authorization: true })
render(
<MCPCard {...defaultProps} data={dataWithNoTools} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
})
describe('Selected State', () => {
it('should apply selected styles when current provider matches', () => {
render(
<MCPCard {...defaultProps} currentProvider={defaultProps.data} />,
{ wrapper: createWrapper() },
)
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
expect(card).toBeInTheDocument()
})
it('should not apply selected styles when different provider', () => {
const differentProvider = createMockData({ id: 'different-id' })
render(
<MCPCard {...defaultProps} currentProvider={differentProvider} />,
{ wrapper: createWrapper() },
)
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
expect(card).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call handleSelect when card is clicked', () => {
const handleSelect = vi.fn()
render(
<MCPCard {...defaultProps} handleSelect={handleSelect} />,
{ wrapper: createWrapper() },
)
const card = screen.getByText('Test MCP Server').closest('[class*="cursor-pointer"]')
if (card) {
fireEvent.click(card)
expect(handleSelect).toHaveBeenCalledWith('mcp-1')
}
})
})
describe('Card Icon', () => {
it('should render card icon', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Icon component is rendered
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Status Indicator', () => {
it('should show green indicator when authorized and has tools', () => {
const data = createMockData({ is_team_authorization: true, tools: [{ name: 'tool1' }] })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
// Should have green indicator (not showing red badge)
expect(screen.queryByText('tools.mcp.noConfigured')).not.toBeInTheDocument()
})
it('should show red indicator when not configured', () => {
const data = createMockData({ is_team_authorization: false })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle long MCP name', () => {
const longName = 'A'.repeat(100)
const data = createMockData({ name: longName })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in name', () => {
const data = createMockData({ name: 'Test <Script> & "Quotes"' })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test <Script> & "Quotes"')).toBeInTheDocument()
})
it('should handle undefined currentProvider', () => {
render(
<MCPCard {...defaultProps} currentProvider={undefined} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
})
describe('Operation Dropdown', () => {
it('should render operation dropdown for workspace managers', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
})
it('should stop propagation when clicking on dropdown container', () => {
const handleSelect = vi.fn()
render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { wrapper: createWrapper() })
// Click on the dropdown area (which should stop propagation)
const dropdown = screen.getByTestId('operation-dropdown')
const dropdownContainer = dropdown.closest('[class*="absolute"]')
if (dropdownContainer) {
fireEvent.click(dropdownContainer)
// handleSelect should NOT be called because stopPropagation
expect(handleSelect).not.toHaveBeenCalled()
}
})
})
describe('Update Modal', () => {
it('should open update modal when edit button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Click the edit button
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
// Modal should be shown
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
})
it('should close update modal when close button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Close the modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
})
})
it('should call updateMCP and onUpdate when form is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Confirm the form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: 'mcp-1',
})
expect(onUpdate).toHaveBeenCalledWith('mcp-1')
})
})
it('should not call onUpdate when updateMCP fails', async () => {
mockUpdateMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Confirm the form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalled()
})
// onUpdate should not be called because result is not 'success'
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Delete Confirm', () => {
it('should open delete confirm when remove button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Click the remove button
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close delete confirm when cancel button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-delete-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deleteMCP and onDeleted when delete is confirmed', async () => {
const onDeleted = vi.fn()
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
expect(onDeleted).toHaveBeenCalled()
})
})
it('should not call onDeleted when deleteMCP fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
const onDeleted = vi.fn()
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
// onDeleted should not be called because result is not 'success'
expect(onDeleted).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,162 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AuthenticationSection from './authentication-section'
describe('AuthenticationSection', () => {
const defaultProps = {
isDynamicRegistration: true,
onDynamicRegistrationChange: vi.fn(),
clientID: '',
onClientIDChange: vi.fn(),
credentials: '',
onCredentialsChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
})
it('should render switch for dynamic registration', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should render client ID input', () => {
render(<AuthenticationSection {...defaultProps} clientID="test-client-id" />)
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument()
})
it('should render credentials input', () => {
render(<AuthenticationSection {...defaultProps} credentials="test-secret" />)
expect(screen.getByDisplayValue('test-secret')).toBeInTheDocument()
})
it('should render labels for all fields', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.clientID')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.clientSecret')).toBeInTheDocument()
})
})
describe('Dynamic Registration Toggle', () => {
it('should not show warning when isDynamicRegistration is true', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
expect(screen.queryByText('tools.mcp.modal.redirectUrlWarning')).not.toBeInTheDocument()
})
it('should show warning when isDynamicRegistration is false', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
expect(screen.getByText('tools.mcp.modal.redirectUrlWarning')).toBeInTheDocument()
})
it('should show OAuth callback URL in warning', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
expect(screen.getByText(/\/mcp\/oauth\/callback/)).toBeInTheDocument()
})
it('should disable inputs when isDynamicRegistration is true', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toBeDisabled()
})
})
it('should enable inputs when isDynamicRegistration is false', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).not.toBeDisabled()
})
})
})
describe('User Interactions', () => {
it('should call onDynamicRegistrationChange when switch is toggled', () => {
const onDynamicRegistrationChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
onDynamicRegistrationChange={onDynamicRegistrationChange}
/>,
)
const switchElement = screen.getByRole('switch')
fireEvent.click(switchElement)
expect(onDynamicRegistrationChange).toHaveBeenCalled()
})
it('should call onClientIDChange when client ID input changes', () => {
const onClientIDChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
isDynamicRegistration={false}
onClientIDChange={onClientIDChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const clientIDInput = inputs[0]
fireEvent.change(clientIDInput, { target: { value: 'new-client-id' } })
expect(onClientIDChange).toHaveBeenCalledWith('new-client-id')
})
it('should call onCredentialsChange when credentials input changes', () => {
const onCredentialsChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
isDynamicRegistration={false}
onCredentialsChange={onCredentialsChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const credentialsInput = inputs[1]
fireEvent.change(credentialsInput, { target: { value: 'new-secret' } })
expect(onCredentialsChange).toHaveBeenCalledWith('new-secret')
})
})
describe('Props', () => {
it('should display provided clientID value', () => {
render(<AuthenticationSection {...defaultProps} clientID="my-client-123" />)
expect(screen.getByDisplayValue('my-client-123')).toBeInTheDocument()
})
it('should display provided credentials value', () => {
render(<AuthenticationSection {...defaultProps} credentials="secret-456" />)
expect(screen.getByDisplayValue('secret-456')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty string values', () => {
render(<AuthenticationSection {...defaultProps} clientID="" credentials="" />)
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(2)
inputs.forEach((input) => {
expect(input).toHaveValue('')
})
})
it('should handle special characters in values', () => {
render(
<AuthenticationSection
{...defaultProps}
clientID="client@123!#$"
credentials="secret&*()_+"
/>,
)
expect(screen.getByDisplayValue('client@123!#$')).toBeInTheDocument()
expect(screen.getByDisplayValue('secret&*()_+')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,78 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import Input from '@/app/components/base/input'
import Switch from '@/app/components/base/switch'
import { API_PREFIX } from '@/config'
import { cn } from '@/utils/classnames'
type AuthenticationSectionProps = {
isDynamicRegistration: boolean
onDynamicRegistrationChange: (value: boolean) => void
clientID: string
onClientIDChange: (value: string) => void
credentials: string
onCredentialsChange: (value: string) => void
}
const AuthenticationSection: FC<AuthenticationSectionProps> = ({
isDynamicRegistration,
onDynamicRegistrationChange,
clientID,
onClientIDChange,
credentials,
onCredentialsChange,
}) => {
const { t } = useTranslation()
return (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={isDynamicRegistration}
onChange={onDynamicRegistrationChange}
/>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
</div>
{!isDynamicRegistration && (
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-secondary">
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
</div>
<Input
value={clientID}
onChange={e => onClientIDChange(e.target.value)}
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
</div>
<Input
value={credentials}
onChange={e => onCredentialsChange(e.target.value)}
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
export default AuthenticationSection

View File

@ -0,0 +1,100 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ConfigurationsSection from './configurations-section'
describe('ConfigurationsSection', () => {
const defaultProps = {
timeout: 30,
onTimeoutChange: vi.fn(),
sseReadTimeout: 300,
onSseReadTimeoutChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ConfigurationsSection {...defaultProps} />)
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
expect(screen.getByDisplayValue('300')).toBeInTheDocument()
})
it('should render timeout input with correct value', () => {
render(<ConfigurationsSection {...defaultProps} />)
const timeoutInput = screen.getByDisplayValue('30')
expect(timeoutInput).toHaveAttribute('type', 'number')
})
it('should render SSE read timeout input with correct value', () => {
render(<ConfigurationsSection {...defaultProps} />)
const sseInput = screen.getByDisplayValue('300')
expect(sseInput).toHaveAttribute('type', 'number')
})
it('should render labels for both inputs', () => {
render(<ConfigurationsSection {...defaultProps} />)
// i18n keys are rendered as-is in test environment
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display custom timeout value', () => {
render(<ConfigurationsSection {...defaultProps} timeout={60} />)
expect(screen.getByDisplayValue('60')).toBeInTheDocument()
})
it('should display custom SSE read timeout value', () => {
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={600} />)
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onTimeoutChange when timeout input changes', () => {
const onTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
const timeoutInput = screen.getByDisplayValue('30')
fireEvent.change(timeoutInput, { target: { value: '45' } })
expect(onTimeoutChange).toHaveBeenCalledWith(45)
})
it('should call onSseReadTimeoutChange when SSE timeout input changes', () => {
const onSseReadTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />)
const sseInput = screen.getByDisplayValue('300')
fireEvent.change(sseInput, { target: { value: '500' } })
expect(onSseReadTimeoutChange).toHaveBeenCalledWith(500)
})
it('should handle numeric conversion correctly', () => {
const onTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
const timeoutInput = screen.getByDisplayValue('30')
fireEvent.change(timeoutInput, { target: { value: '0' } })
expect(onTimeoutChange).toHaveBeenCalledWith(0)
})
})
describe('Edge Cases', () => {
it('should handle zero timeout value', () => {
render(<ConfigurationsSection {...defaultProps} timeout={0} />)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle zero SSE read timeout value', () => {
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={0} />)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle large timeout values', () => {
render(<ConfigurationsSection {...defaultProps} timeout={9999} sseReadTimeout={9999} />)
expect(screen.getAllByDisplayValue('9999')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,49 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
type ConfigurationsSectionProps = {
timeout: number
onTimeoutChange: (timeout: number) => void
sseReadTimeout: number
onSseReadTimeoutChange: (timeout: number) => void
}
const ConfigurationsSection: FC<ConfigurationsSectionProps> = ({
timeout,
onTimeoutChange,
sseReadTimeout,
onSseReadTimeoutChange,
}) => {
const { t } = useTranslation()
return (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={timeout}
onChange={e => onTimeoutChange(Number(e.target.value))}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={sseReadTimeout}
onChange={e => onSseReadTimeoutChange(Number(e.target.value))}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
</>
)
}
export default ConfigurationsSection

View File

@ -0,0 +1,192 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersSection from './headers-section'
describe('HeadersSection', () => {
const defaultProps = {
headers: [],
onHeadersChange: vi.fn(),
isCreate: true,
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
})
it('should render headers label', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
})
it('should render headers tip', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
it('should render empty state when no headers', () => {
render(<HeadersSection {...defaultProps} headers={[]} />)
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
})
it('should render add header button when empty', () => {
render(<HeadersSection {...defaultProps} headers={[]} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
})
describe('With Headers', () => {
const headersWithItems = [
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
{ id: '2', key: 'Content-Type', value: 'application/json' },
]
it('should render header items', () => {
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
})
it('should show masked tip when not isCreate and has headers with content', () => {
render(
<HeadersSection
{...defaultProps}
isCreate={false}
headers={headersWithItems}
/>,
)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
it('should not show masked tip when isCreate is true', () => {
render(
<HeadersSection
{...defaultProps}
isCreate={true}
headers={headersWithItems}
/>,
)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHeadersChange when adding a header', () => {
const onHeadersChange = vi.fn()
render(<HeadersSection {...defaultProps} onHeadersChange={onHeadersChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onHeadersChange).toHaveBeenCalled()
const calledWithHeaders = onHeadersChange.mock.calls[0][0]
expect(calledWithHeaders).toHaveLength(1)
expect(calledWithHeaders[0]).toHaveProperty('id')
expect(calledWithHeaders[0]).toHaveProperty('key', '')
expect(calledWithHeaders[0]).toHaveProperty('value', '')
})
it('should call onHeadersChange when editing header key', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: '', value: '' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const keyInput = inputs[0]
fireEvent.change(keyInput, { target: { value: 'X-Custom-Header' } })
expect(onHeadersChange).toHaveBeenCalled()
})
it('should call onHeadersChange when editing header value', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: 'X-Custom-Header', value: '' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const valueInput = inputs[1]
fireEvent.change(valueInput, { target: { value: 'custom-value' } })
expect(onHeadersChange).toHaveBeenCalled()
})
it('should call onHeadersChange when removing a header', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: 'X-Header', value: 'value' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
// Find and click the delete button
const deleteButton = screen.getByRole('button', { name: '' })
fireEvent.click(deleteButton)
expect(onHeadersChange).toHaveBeenCalledWith([])
})
})
describe('Props', () => {
it('should pass isCreate=true correctly (no masking)', () => {
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={true} headers={headers} />)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should pass isCreate=false correctly (with masking)', () => {
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle headers with empty keys (no masking even when not isCreate)', () => {
const headers = [{ id: '1', key: '', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// Empty key headers don't trigger masking
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should handle headers with whitespace-only keys', () => {
const headers = [{ id: '1', key: ' ', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// Whitespace-only key doesn't count as having content
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should handle multiple headers where some have empty keys', () => {
const headers = [
{ id: '1', key: '', value: 'Value1' },
{ id: '2', key: 'ValidKey', value: 'Value2' },
]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// At least one header has a non-empty key, so masking should apply
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,36 @@
'use client'
import type { FC } from 'react'
import type { HeaderItem } from '../headers-input'
import { useTranslation } from 'react-i18next'
import HeadersInput from '../headers-input'
type HeadersSectionProps = {
headers: HeaderItem[]
onHeadersChange: (headers: HeaderItem[]) => void
isCreate: boolean
}
const HeadersSection: FC<HeadersSectionProps> = ({
headers,
onHeadersChange,
isCreate,
}) => {
const { t } = useTranslation()
return (
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
<HeadersInput
headersItems={headers}
onChange={onHeadersChange}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
export default HeadersSection

View File

@ -422,16 +422,6 @@
"count": 6
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx": {
"ts/no-explicit-any": {
"count": 8
@ -455,14 +445,6 @@
"count": 3
}
},
"app/components/app/configuration/debug/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 11
}
},
"app/components/app/configuration/debug/types.ts": {
"ts/no-explicit-any": {
"count": 1
@ -2645,19 +2627,6 @@
"count": 1
}
},
"app/components/tools/mcp/mcp-service-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/tools/mcp/modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 20
}
},
"app/components/tools/mcp/provider-card.tsx": {
"ts/no-explicit-any": {
"count": 3

View File

@ -119,15 +119,15 @@
"mime": "4.1.0",
"mitt": "3.0.1",
"negotiator": "1.0.0",
"next": "16.1.5",
"next": "16.1.4",
"next-themes": "0.4.6",
"nuqs": "2.8.6",
"pinyin-pro": "3.27.0",
"qrcode.react": "4.2.0",
"qs": "6.14.1",
"react": "19.2.4",
"react": "19.2.3",
"react-18-input-autosize": "3.0.0",
"react-dom": "19.2.4",
"react-dom": "19.2.3",
"react-easy-crop": "5.5.3",
"react-hotkeys-hook": "4.6.2",
"react-i18next": "16.5.0",
@ -165,9 +165,9 @@
"@eslint-react/eslint-plugin": "2.7.0",
"@mdx-js/loader": "3.1.1",
"@mdx-js/react": "3.1.1",
"@next/bundle-analyzer": "16.1.5",
"@next/eslint-plugin-next": "16.1.5",
"@next/mdx": "16.1.5",
"@next/bundle-analyzer": "16.1.4",
"@next/eslint-plugin-next": "16.1.4",
"@next/mdx": "16.1.4",
"@rgrove/parse-xml": "4.2.0",
"@serwist/turbopack": "9.5.0",
"@storybook/addon-docs": "10.2.0",

1024
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import type {
} from '@/types/workflow'
import { get, post } from './base'
import { getFlowPrefix } from './utils'
import { sanitizeWorkflowDraftPayload } from './workflow-payload'
export const fetchWorkflowDraft = (url: string) => {
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: {
url: string
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
}) => {
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: params }, { silent: true })
const sanitized = sanitizeWorkflowDraftPayload(params)
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: sanitized }, { silent: true })
}
export const fetchNodesDefaultConfigs = (url: string) => {

View File

@ -1,4 +1,4 @@
import { cleanup } from '@testing-library/react'
import { act, cleanup } from '@testing-library/react'
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
import '@testing-library/jest-dom/vitest'
@ -78,8 +78,13 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
Element.prototype.scrollIntoView = function () { /* noop */ }
afterEach(() => {
cleanup()
afterEach(async () => {
// Wrap cleanup in act() to flush pending React scheduler work
// This prevents "window is not defined" errors from React 19's scheduler
// which uses setImmediate/MessageChannel that can fire after jsdom cleanup
await act(async () => {
cleanup()
})
})
// mock next/image to avoid width/height requirements for data URLs