Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing

# Conflicts:
#	web/app/components/header/account-setting/model-provider-page/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx
This commit is contained in:
yyh
2026-03-09 12:12:27 +08:00
77 changed files with 6786 additions and 1321 deletions

View File

@ -25,6 +25,10 @@ updates:
interval: "weekly"
open-pull-requests-limit: 2
groups:
lexical:
patterns:
- "lexical"
- "@lexical/*"
storybook:
patterns:
- "storybook"
@ -33,5 +37,7 @@ updates:
patterns:
- "*"
exclude-patterns:
- "lexical"
- "@lexical/*"
- "storybook"
- "@storybook/*"

View File

@ -1,7 +1,6 @@
import hashlib
import logging
import time
from threading import Thread
from threading import Thread, Timer
from typing import Union
from flask import Flask, current_app
@ -96,9 +95,9 @@ class MessageCycleManager:
if auto_generate_conversation_name and is_first_message:
# start generate thread
# time.sleep not block other logic
time.sleep(1)
thread = Thread(
target=self._generate_conversation_name_worker,
thread = Timer(
1,
self._generate_conversation_name_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"conversation_id": conversation_id,

View File

@ -4,6 +4,7 @@ import json
import logging
import os
import tempfile
import zipfile
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any
@ -385,6 +386,32 @@ def parser_docx_part(block, doc: Document, content_items, i):
content_items.append((i, "table", Table(block, doc)))
def _normalize_docx_zip(file_content: bytes) -> bytes:
"""
Some DOCX files (e.g. exported by Evernote on Windows) are malformed:
ZIP entry names use backslash (\\) as path separator instead of the forward
slash (/) required by both the ZIP spec and OOXML. On Linux/Mac the entry
"word\\document.xml" is never found when python-docx looks for
"word/document.xml", which triggers a KeyError about a missing relationship.
This function rewrites the ZIP in-memory, normalizing all entry names to
use forward slashes without touching any actual document content.
"""
try:
with zipfile.ZipFile(io.BytesIO(file_content), "r") as zin:
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
# Normalize backslash path separators to forward slash
item.filename = item.filename.replace("\\", "/")
zout.writestr(item, data)
return out_buf.getvalue()
except zipfile.BadZipFile:
# Not a valid zip — return as-is and let python-docx report the real error
return file_content
def _extract_text_from_docx(file_content: bytes) -> str:
"""
Extract text from a DOCX file.
@ -392,7 +419,15 @@ def _extract_text_from_docx(file_content: bytes) -> str:
"""
try:
doc_file = io.BytesIO(file_content)
doc = docx.Document(doc_file)
try:
doc = docx.Document(doc_file)
except Exception as e:
logger.warning("Failed to parse DOCX, attempting to normalize ZIP entry paths: %s", e)
# Some DOCX files exported by tools like Evernote on Windows use
# backslash path separators in ZIP entries and/or single-quoted XML
# attributes, both of which break python-docx on Linux. Normalize and retry.
file_content = _normalize_docx_zip(file_content)
doc = docx.Document(io.BytesIO(file_content))
text = []
# Keep track of paragraph and table positions

View File

@ -23,7 +23,11 @@ from dify_graph.variables import (
)
from dify_graph.variables.segments import ArrayObjectSegment
from .entities import KnowledgeRetrievalNodeData
from .entities import (
Condition,
KnowledgeRetrievalNodeData,
MetadataFilteringCondition,
)
from .exc import (
KnowledgeRetrievalNodeError,
RateLimitExceededError,
@ -171,6 +175,12 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
if node_data.metadata_filtering_mode is not None:
metadata_filtering_mode = node_data.metadata_filtering_mode
resolved_metadata_conditions = (
self._resolve_metadata_filtering_conditions(node_data.metadata_filtering_conditions)
if node_data.metadata_filtering_conditions
else None
)
if str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE and query:
# fetch model config
if node_data.single_retrieval_config is None:
@ -189,7 +199,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
model_mode=model.mode,
model_name=model.name,
metadata_model_config=node_data.metadata_model_config,
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
metadata_filtering_conditions=resolved_metadata_conditions,
metadata_filtering_mode=metadata_filtering_mode,
query=query,
)
@ -247,7 +257,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
weights=weights,
reranking_enable=node_data.multiple_retrieval_config.reranking_enable,
metadata_model_config=node_data.metadata_model_config,
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
metadata_filtering_conditions=resolved_metadata_conditions,
metadata_filtering_mode=metadata_filtering_mode,
attachment_ids=[attachment.related_id for attachment in attachments] if attachments else None,
)
@ -256,6 +266,48 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
usage = self._rag_retrieval.llm_usage
return retrieval_resource_list, usage
def _resolve_metadata_filtering_conditions(
self, conditions: MetadataFilteringCondition
) -> MetadataFilteringCondition:
if conditions.conditions is None:
return MetadataFilteringCondition(
logical_operator=conditions.logical_operator,
conditions=None,
)
variable_pool = self.graph_runtime_state.variable_pool
resolved_conditions: list[Condition] = []
for cond in conditions.conditions or []:
value = cond.value
if isinstance(value, str):
segment_group = variable_pool.convert_template(value)
if len(segment_group.value) == 1:
resolved_value = segment_group.value[0].to_object()
else:
resolved_value = segment_group.text
elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value):
resolved_values = []
for v in value: # type: ignore
segment_group = variable_pool.convert_template(v)
if len(segment_group.value) == 1:
resolved_values.append(segment_group.value[0].to_object())
else:
resolved_values.append(segment_group.text)
resolved_value = resolved_values
else:
resolved_value = value
resolved_conditions.append(
Condition(
name=cond.name,
comparison_operator=cond.comparison_operator,
value=resolved_value,
)
)
return MetadataFilteringCondition(
logical_operator=conditions.logical_operator or "and",
conditions=resolved_conditions,
)
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,

View File

@ -0,0 +1,70 @@
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
RemoteFileUploadError,
TooManyFilesError,
UnsupportedFileTypeError,
)
class TestFilenameNotExistsError:
def test_defaults(self):
error = FilenameNotExistsError()
assert error.code == 400
assert error.description == "The specified filename does not exist."
class TestRemoteFileUploadError:
def test_defaults(self):
error = RemoteFileUploadError()
assert error.code == 400
assert error.description == "Error uploading remote file."
class TestFileTooLargeError:
def test_defaults(self):
error = FileTooLargeError()
assert error.code == 413
assert error.error_code == "file_too_large"
assert error.description == "File size exceeded. {message}"
class TestUnsupportedFileTypeError:
def test_defaults(self):
error = UnsupportedFileTypeError()
assert error.code == 415
assert error.error_code == "unsupported_file_type"
assert error.description == "File type not allowed."
class TestBlockedFileExtensionError:
def test_defaults(self):
error = BlockedFileExtensionError()
assert error.code == 400
assert error.error_code == "file_extension_blocked"
assert error.description == "The file extension is blocked for security reasons."
class TestTooManyFilesError:
def test_defaults(self):
error = TooManyFilesError()
assert error.code == 400
assert error.error_code == "too_many_files"
assert error.description == "Only one file is allowed."
class TestNoFileUploadedError:
def test_defaults(self):
error = NoFileUploadedError()
assert error.code == 400
assert error.error_code == "no_file_uploaded"
assert error.description == "Please upload your file."

View File

@ -1,22 +1,95 @@
from flask import Response
from controllers.common.file_response import enforce_download_for_html, is_html_content
from controllers.common.file_response import (
_normalize_mime_type,
enforce_download_for_html,
is_html_content,
)
class TestFileResponseHelpers:
def test_is_html_content_detects_mime_type(self):
class TestNormalizeMimeType:
def test_returns_empty_string_for_none(self):
assert _normalize_mime_type(None) == ""
def test_returns_empty_string_for_empty_string(self):
assert _normalize_mime_type("") == ""
def test_normalizes_mime_type(self):
assert _normalize_mime_type("Text/HTML; Charset=UTF-8") == "text/html"
class TestIsHtmlContent:
def test_detects_html_via_mime_type(self):
mime_type = "text/html; charset=UTF-8"
result = is_html_content(mime_type, filename="file.txt", extension="txt")
result = is_html_content(
mime_type=mime_type,
filename="file.txt",
extension="txt",
)
assert result is True
def test_is_html_content_detects_extension(self):
result = is_html_content("text/plain", filename="report.html", extension=None)
def test_detects_html_via_extension_argument(self):
result = is_html_content(
mime_type="text/plain",
filename=None,
extension="html",
)
assert result is True
def test_enforce_download_for_html_sets_headers(self):
def test_detects_html_via_filename_extension(self):
result = is_html_content(
mime_type="text/plain",
filename="report.html",
extension=None,
)
assert result is True
def test_returns_false_when_no_html_detected_anywhere(self):
"""
Missing negative test:
- MIME type is not HTML
- filename has no HTML extension
- extension argument is not HTML
"""
result = is_html_content(
mime_type="application/json",
filename="data.json",
extension="json",
)
assert result is False
def test_returns_false_when_all_inputs_are_none(self):
result = is_html_content(
mime_type=None,
filename=None,
extension=None,
)
assert result is False
class TestEnforceDownloadForHtml:
def test_sets_attachment_when_filename_missing(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
response,
mime_type="text/html",
filename=None,
extension="html",
)
assert updated is True
assert response.headers["Content-Disposition"] == "attachment"
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_sets_headers_when_filename_present(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
@ -27,11 +100,12 @@ class TestFileResponseHelpers:
)
assert updated is True
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Disposition"].startswith("attachment")
assert "unsafe.html" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_enforce_download_for_html_no_change_for_non_html(self):
def test_does_not_modify_response_for_non_html_content(self):
response = Response("payload", mimetype="text/plain")
updated = enforce_download_for_html(

View File

@ -0,0 +1,188 @@
from uuid import UUID
import httpx
import pytest
from controllers.common import helpers
from controllers.common.helpers import FileInfo, guess_file_info_from_response
def make_response(
url="https://example.com/file.txt",
headers=None,
content=None,
):
return httpx.Response(
200,
request=httpx.Request("GET", url),
headers=headers or {},
content=content or b"",
)
class TestGuessFileInfoFromResponse:
def test_filename_from_url(self):
response = make_response(
url="https://example.com/test.pdf",
content=b"Hello World",
)
info = guess_file_info_from_response(response)
assert info.filename == "test.pdf"
assert info.extension == ".pdf"
assert info.mimetype == "application/pdf"
def test_filename_from_content_disposition(self):
headers = {
"Content-Disposition": "attachment; filename=myfile.csv",
"Content-Type": "text/csv",
}
response = make_response(
url="https://example.com/",
headers=headers,
content=b"Hello World",
)
info = guess_file_info_from_response(response)
assert info.filename == "myfile.csv"
assert info.extension == ".csv"
assert info.mimetype == "text/csv"
@pytest.mark.parametrize(
("magic_available", "expected_ext"),
[
(True, "txt"),
(False, "bin"),
],
)
def test_generated_filename_when_missing(self, monkeypatch, magic_available, expected_ext):
if magic_available:
if helpers.magic is None:
pytest.skip("python-magic is not installed, cannot run 'magic_available=True' test variant")
else:
monkeypatch.setattr(helpers, "magic", None)
response = make_response(
url="https://example.com/",
content=b"Hello World",
)
info = guess_file_info_from_response(response)
name, ext = info.filename.split(".")
UUID(name)
assert ext == expected_ext
def test_mimetype_from_header_when_unknown(self):
headers = {"Content-Type": "application/json"}
response = make_response(
url="https://example.com/file.unknown",
headers=headers,
content=b'{"a": 1}',
)
info = guess_file_info_from_response(response)
assert info.mimetype == "application/json"
def test_extension_added_when_missing(self):
headers = {"Content-Type": "image/png"}
response = make_response(
url="https://example.com/image",
headers=headers,
content=b"fakepngdata",
)
info = guess_file_info_from_response(response)
assert info.extension == ".png"
assert info.filename.endswith(".png")
def test_content_length_used_as_size(self):
headers = {
"Content-Length": "1234",
"Content-Type": "text/plain",
}
response = make_response(
url="https://example.com/a.txt",
headers=headers,
content=b"a" * 1234,
)
info = guess_file_info_from_response(response)
assert info.size == 1234
def test_size_minus_one_when_header_missing(self):
response = make_response(url="https://example.com/a.txt")
info = guess_file_info_from_response(response)
assert info.size == -1
def test_fallback_to_bin_extension(self):
headers = {"Content-Type": "application/octet-stream"}
response = make_response(
url="https://example.com/download",
headers=headers,
content=b"\x00\x01\x02\x03",
)
info = guess_file_info_from_response(response)
assert info.extension == ".bin"
assert info.filename.endswith(".bin")
def test_return_type(self):
response = make_response()
info = guess_file_info_from_response(response)
assert isinstance(info, FileInfo)
class TestMagicImportWarnings:
@pytest.mark.parametrize(
("platform_name", "expected_message"),
[
("Windows", "pip install python-magic-bin"),
("Darwin", "brew install libmagic"),
("Linux", "sudo apt-get install libmagic1"),
("Other", "install `libmagic`"),
],
)
def test_magic_import_warning_per_platform(
self,
monkeypatch,
platform_name,
expected_message,
):
import builtins
import importlib
# Force ImportError when "magic" is imported
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "magic":
raise ImportError("No module named magic")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
monkeypatch.setattr("platform.system", lambda: platform_name)
# Remove helpers so it imports fresh
import sys
original_helpers = sys.modules.get(helpers.__name__)
sys.modules.pop(helpers.__name__, None)
try:
with pytest.warns(UserWarning, match="To use python-magic") as warning:
imported_helpers = importlib.import_module(helpers.__name__)
assert expected_message in str(warning[0].message)
finally:
if original_helpers is not None:
sys.modules[helpers.__name__] = original_helpers

View File

@ -0,0 +1,189 @@
import sys
from enum import StrEnum
from unittest.mock import MagicMock, patch
import pytest
from flask_restx import Namespace
from pydantic import BaseModel
class UserModel(BaseModel):
id: int
name: str
class ProductModel(BaseModel):
id: int
price: float
@pytest.fixture(autouse=True)
def mock_console_ns():
"""Mock the console_ns to avoid circular imports during test collection."""
mock_ns = MagicMock(spec=Namespace)
mock_ns.models = {}
# Inject mock before importing schema module
with patch.dict(sys.modules, {"controllers.console": MagicMock(console_ns=mock_ns)}):
yield mock_ns
def test_default_ref_template_value():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0
assert DEFAULT_REF_TEMPLATE_SWAGGER_2_0 == "#/definitions/{model}"
def test_register_schema_model_calls_namespace_schema_model():
from controllers.common.schema import register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, UserModel)
namespace.schema_model.assert_called_once()
model_name, schema = namespace.schema_model.call_args.args
assert model_name == "UserModel"
assert isinstance(schema, dict)
assert "properties" in schema
def test_register_schema_model_passes_schema_from_pydantic():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, UserModel)
schema = namespace.schema_model.call_args.args[1]
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
assert schema == expected_schema
def test_register_schema_models_registers_multiple_models():
from controllers.common.schema import register_schema_models
namespace = MagicMock(spec=Namespace)
register_schema_models(namespace, UserModel, ProductModel)
assert namespace.schema_model.call_count == 2
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
assert called_names == ["UserModel", "ProductModel"]
def test_register_schema_models_calls_register_schema_model(monkeypatch):
from controllers.common.schema import register_schema_models
namespace = MagicMock(spec=Namespace)
calls = []
def fake_register(ns, model):
calls.append((ns, model))
monkeypatch.setattr(
"controllers.common.schema.register_schema_model",
fake_register,
)
register_schema_models(namespace, UserModel, ProductModel)
assert calls == [
(namespace, UserModel),
(namespace, ProductModel),
]
class StatusEnum(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
class PriorityEnum(StrEnum):
HIGH = "high"
LOW = "low"
def test_get_or_create_model_returns_existing_model(mock_console_ns):
from controllers.common.schema import get_or_create_model
existing_model = MagicMock()
mock_console_ns.models = {"TestModel": existing_model}
result = get_or_create_model("TestModel", {"key": "value"})
assert result == existing_model
mock_console_ns.model.assert_not_called()
def test_get_or_create_model_creates_new_model_when_not_exists(mock_console_ns):
from controllers.common.schema import get_or_create_model
mock_console_ns.models = {}
new_model = MagicMock()
mock_console_ns.model.return_value = new_model
field_def = {"name": {"type": "string"}}
result = get_or_create_model("NewModel", field_def)
assert result == new_model
mock_console_ns.model.assert_called_once_with("NewModel", field_def)
def test_get_or_create_model_does_not_call_model_if_exists(mock_console_ns):
from controllers.common.schema import get_or_create_model
existing_model = MagicMock()
mock_console_ns.models = {"ExistingModel": existing_model}
result = get_or_create_model("ExistingModel", {"key": "value"})
assert result == existing_model
mock_console_ns.model.assert_not_called()
def test_register_enum_models_registers_single_enum():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum)
namespace.schema_model.assert_called_once()
model_name, schema = namespace.schema_model.call_args.args
assert model_name == "StatusEnum"
assert isinstance(schema, dict)
def test_register_enum_models_registers_multiple_enums():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum, PriorityEnum)
assert namespace.schema_model.call_count == 2
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
assert called_names == ["StatusEnum", "PriorityEnum"]
def test_register_enum_models_uses_correct_ref_template():
from controllers.common.schema import register_enum_models
namespace = MagicMock(spec=Namespace)
register_enum_models(namespace, StatusEnum)
schema = namespace.schema_model.call_args.args[1]
# Verify the schema contains enum values
assert "enum" in schema or "anyOf" in schema

View File

@ -124,12 +124,12 @@ def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch):
def start(self):
self.started = True
def fake_thread(**kwargs):
def fake_thread(*args, **kwargs):
thread = DummyThread(**kwargs)
captured["thread"] = thread
return thread
monkeypatch.setattr(message_cycle_manager, "Thread", fake_thread)
monkeypatch.setattr(message_cycle_manager, "Timer", fake_thread)
manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock())
thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello")

View File

@ -8,7 +8,9 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from dify_graph.enums import WorkflowNodeExecutionStatus
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
from dify_graph.nodes.knowledge_retrieval.entities import (
Condition,
KnowledgeRetrievalNodeData,
MetadataFilteringCondition,
MultipleRetrievalConfig,
RerankingModelConfig,
SingleRetrievalConfig,
@ -593,3 +595,106 @@ class TestFetchDatasetRetriever:
# Assert
assert version == "1"
def test_resolve_metadata_filtering_conditions_templates(
self,
mock_graph_init_params,
mock_graph_runtime_state,
mock_rag_retrieval,
):
"""_resolve_metadata_filtering_conditions should expand {{#...#}} and keep numbers/None unchanged."""
# Arrange
node_id = str(uuid.uuid4())
config = {
"id": node_id,
"data": {
"title": "Knowledge Retrieval",
"type": "knowledge-retrieval",
"dataset_ids": [str(uuid.uuid4())],
"retrieval_mode": "multiple",
},
}
# Variable in pool used by template
mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme"))
node = KnowledgeRetrievalNode(
id=node_id,
config=config,
graph_init_params=mock_graph_init_params,
graph_runtime_state=mock_graph_runtime_state,
rag_retrieval=mock_rag_retrieval,
)
conditions = MetadataFilteringCondition(
logical_operator="and",
conditions=[
Condition(name="document_name", comparison_operator="is", value="{{#start.query#}}"),
Condition(name="tags", comparison_operator="in", value=["x", "{{#start.query#}}"]),
Condition(name="year", comparison_operator="=", value=2025),
],
)
# Act
resolved = node._resolve_metadata_filtering_conditions(conditions)
# Assert
assert resolved.logical_operator == "and"
assert resolved.conditions[0].value == "readme"
assert isinstance(resolved.conditions[1].value, list)
assert resolved.conditions[1].value[1] == "readme"
assert resolved.conditions[2].value == 2025
def test_fetch_passes_resolved_metadata_conditions(
self,
mock_graph_init_params,
mock_graph_runtime_state,
mock_rag_retrieval,
):
"""_fetch_dataset_retriever should pass resolved metadata conditions into request."""
# Arrange
query = "hi"
variables = {"query": query}
mock_graph_runtime_state.variable_pool.add(["start", "q"], StringSegment(value="readme"))
node_data = KnowledgeRetrievalNodeData(
title="Knowledge Retrieval",
type="knowledge-retrieval",
dataset_ids=[str(uuid.uuid4())],
retrieval_mode="multiple",
multiple_retrieval_config=MultipleRetrievalConfig(
top_k=4,
score_threshold=0.0,
reranking_mode="reranking_model",
reranking_enable=True,
reranking_model=RerankingModelConfig(provider="cohere", model="rerank-v2"),
),
metadata_filtering_mode="manual",
metadata_filtering_conditions=MetadataFilteringCondition(
logical_operator="and",
conditions=[
Condition(name="document_name", comparison_operator="is", value="{{#start.q#}}"),
],
),
)
node_id = str(uuid.uuid4())
config = {"id": node_id, "data": node_data.model_dump()}
node = KnowledgeRetrievalNode(
id=node_id,
config=config,
graph_init_params=mock_graph_init_params,
graph_runtime_state=mock_graph_runtime_state,
rag_retrieval=mock_rag_retrieval,
)
mock_rag_retrieval.knowledge_retrieval.return_value = []
mock_rag_retrieval.llm_usage = LLMUsage.empty_usage()
# Act
node._fetch_dataset_retriever(node_data=node_data, variables=variables)
# Assert the passed request has resolved value
call_args = mock_rag_retrieval.knowledge_retrieval.call_args
request = call_args[1]["request"]
assert request.metadata_filtering_conditions is not None
assert request.metadata_filtering_conditions.conditions[0].value == "readme"

View File

@ -16,6 +16,7 @@ from dify_graph.nodes.document_extractor.node import (
_extract_text_from_excel,
_extract_text_from_pdf,
_extract_text_from_plain_text,
_normalize_docx_zip,
)
from dify_graph.variables import ArrayFileSegment
from dify_graph.variables.segments import ArrayStringSegment
@ -385,3 +386,58 @@ def test_extract_text_from_excel_numeric_type_column(mock_excel_file):
expected_manual = "| 1.0 | 1.1 |\n| --- | --- |\n| Test | Test |\n\n"
assert expected_manual == result
def _make_docx_zip(use_backslash: bool) -> bytes:
"""Helper to build a minimal in-memory DOCX zip.
When use_backslash=True the ZIP entry names use backslash separators
(as produced by Evernote on Windows), otherwise forward slashes are used.
"""
import zipfile
sep = "\\" if use_backslash else "/"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", b"<Types/>")
zf.writestr(f"_rels{sep}.rels", b"<Relationships/>")
zf.writestr(f"word{sep}document.xml", b"<w:document/>")
zf.writestr(f"word{sep}_rels{sep}document.xml.rels", b"<Relationships/>")
return buf.getvalue()
def test_normalize_docx_zip_replaces_backslashes():
"""ZIP entries with backslash separators must be rewritten to forward slashes."""
import zipfile
malformed = _make_docx_zip(use_backslash=True)
fixed = _normalize_docx_zip(malformed)
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
names = zf.namelist()
assert "word/document.xml" in names
assert "word/_rels/document.xml.rels" in names
# No entry should contain a backslash after normalization
assert all("\\" not in name for name in names)
def test_normalize_docx_zip_leaves_forward_slash_unchanged():
"""ZIP entries that already use forward slashes must not be modified."""
import zipfile
normal = _make_docx_zip(use_backslash=False)
fixed = _normalize_docx_zip(normal)
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
names = zf.namelist()
assert "word/document.xml" in names
assert "word/_rels/document.xml.rels" in names
def test_normalize_docx_zip_returns_original_on_bad_zip():
"""Non-zip bytes must be returned as-is without raising."""
garbage = b"not a zip file at all"
result = _normalize_docx_zip(garbage)
assert result == garbage

View File

@ -2,6 +2,12 @@
- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions.
## Overlay Components (Mandatory)
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
## Automated Test Generation
- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests.

View File

@ -503,7 +503,7 @@ describe('TimePicker', () => {
const emitted = onChange.mock.calls[0][0]
expect(isDayjsObject(emitted)).toBe(true)
// 10:30 UTC converted to America/New_York (UTC-5 in Jan) = 05:30
expect(emitted.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
expect(emitted.utcOffset()).toBe(dayjs.tz('2024-01-01', 'America/New_York').utcOffset())
expect(emitted.hour()).toBe(5)
expect(emitted.minute()).toBe(30)
})

View File

@ -20,7 +20,7 @@ describe('dayjs utilities', () => {
const result = toDayjs('07:15 PM', { timezone: tz })
expect(result).toBeDefined()
expect(result?.format('HH:mm')).toBe('19:15')
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).utcOffset())
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).startOf('day').utcOffset())
})
it('isDayjsObject detects dayjs instances', () => {

View File

@ -0,0 +1,257 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '../index'
describe('context-menu wrapper', () => {
describe('ContextMenuContent', () => {
it('should position content at bottom-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
<ContextMenuItem>Content action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
placement="top-end"
sideOffset={12}
alignOffset={-3}
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
>
<ContextMenuItem>Custom content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
positionerProps={{
'role': 'group',
'aria-label': 'context content positioner',
'id': 'context-content-positioner',
'onMouseEnter': handlePositionerMouseEnter,
}}
popupProps={{
role: 'menu',
id: 'context-content-popup',
onClick: handlePopupClick,
}}
>
<ContextMenuItem>Passthrough content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'context content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
expect(popup).toHaveAttribute('id', 'context-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('ContextMenuSubContent', () => {
it('should position sub-content at right-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
<ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
<ContextMenuItem>Sub action</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
})
describe('destructive prop behavior', () => {
it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
destructive={destructive}
aria-label="menu action"
id={`context-item-${String(destructive)}`}
onClick={handleClick}
>
Item label
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`)
expect(item).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger
destructive={destructive}
aria-label="submenu action"
id={`context-sub-${String(destructive)}`}
onClick={handleClick}
>
Trigger item
</ContextMenuSubTrigger>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`)
expect(trigger).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="context docs link"
id={`context-link-${String(destructive)}`}
target="_blank"
rel="noopener noreferrer"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'context docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
})
})
describe('ContextMenuLinkItem close behavior', () => {
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
})
describe('ContextMenuTrigger interaction', () => {
it('should open menu when right-clicking trigger area', () => {
render(
<ContextMenu>
<ContextMenuTrigger aria-label="context trigger area">
Trigger area
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Open on right click</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByLabelText('context trigger area')
fireEvent.contextMenu(trigger)
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
})
})
describe('ContextMenuSeparator', () => {
it('should render separator and keep surrounding rows when separator is between items', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>First action</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>Second action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,215 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuCheckboxItemIndicator,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRadioItemIndicator,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '.'
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
<ContextMenuTrigger
aria-label="context menu trigger area"
render={<button type="button" className="flex h-44 w-80 select-none items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary" />}
>
{label}
</ContextMenuTrigger>
)
const meta = {
title: 'Base/Navigation/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ContextMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Edit</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
<ContextMenuItem>Archive</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithSubmenu: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Paste</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithGroupLabel: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
<ContextMenuItem>Rename</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
<ContextMenuItem destructive>Delete</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
),
}
const WithRadioItemsDemo = () => {
const [value, setValue] = useState('comfortable')
return (
<ContextMenu>
<TriggerArea label={`Right-click to set density: ${value}`} />
<ContextMenuContent>
<ContextMenuRadioGroup value={value} onValueChange={setValue}>
<ContextMenuRadioItem value="compact">
Compact
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="comfortable">
Comfortable
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="spacious">
Spacious
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
const [showToolbar, setShowToolbar] = useState(true)
const [showSidebar, setShowSidebar] = useState(false)
const [showStatusBar, setShowStatusBar] = useState(true)
return (
<ContextMenu>
<TriggerArea label="Right-click to configure panel visibility" />
<ContextMenuContent>
<ContextMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
Toolbar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
Sidebar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Status bar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithCheckboxItems: Story = {
render: () => <WithCheckboxItemsDemo />,
}
export const WithLinkItems: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to open links" />
<ContextMenuContent>
<ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</ContextMenuLinkItem>
<ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</ContextMenuLinkItem>
<ContextMenuSeparator />
<ContextMenuLinkItem destructive href="https://example.com/delete" rel="noopener noreferrer" target="_blank">
Dangerous External Action
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const Complex: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to inspect all menu capabilities" />
<ContextMenuContent>
<ContextMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Rename
</ContextMenuItem>
<ContextMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
Share
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy Link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}

View File

@ -0,0 +1,302 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import * as React from 'react'
import {
menuBackdropClassName,
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const ContextMenu = BaseContextMenu.Root
export const ContextMenuTrigger = BaseContextMenu.Trigger
export const ContextMenuPortal = BaseContextMenu.Portal
export const ContextMenuBackdrop = BaseContextMenu.Backdrop
export const ContextMenuSub = BaseContextMenu.SubmenuRoot
export const ContextMenuGroup = BaseContextMenu.Group
export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup
type ContextMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>,
'children' | 'className'
>
}
type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
withBackdrop?: boolean
}
function renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop = false,
}: ContextMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BaseContextMenu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BaseContextMenu.Popup>
</BaseContextMenu.Positioner>
</BaseContextMenu.Portal>
)
}
export function ContextMenuContent({
children,
placement = 'bottom-start',
sideOffset = 0,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop: true,
})
}
type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item> & {
destructive?: boolean
}
export function ContextMenuItem({
className,
destructive,
...props
}: ContextMenuItemProps) {
return (
<BaseContextMenu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.LinkItem> & {
destructive?: boolean
}
export function ContextMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: ContextMenuLinkItemProps) {
return (
<BaseContextMenu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
)
}
export function ContextMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
return (
<BaseContextMenu.RadioItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
export function ContextMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
return (
<BaseContextMenu.CheckboxItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
children?: React.ReactNode
}
export function ContextMenuItemIndicator({
className,
children,
...props
}: ContextMenuIndicatorProps) {
return (
<span
aria-hidden
className={cn(menuIndicatorClassName, className)}
{...props}
>
{children ?? <span aria-hidden className="i-ri-check-line h-4 w-4" />}
</span>
)
}
export function ContextMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.CheckboxItemIndicator>
)
}
export function ContextMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
return (
<BaseContextMenu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.RadioItemIndicator>
)
}
type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger> & {
destructive?: boolean
}
export function ContextMenuSubTrigger({
className,
destructive,
children,
...props
}: ContextMenuSubTriggerProps) {
return (
<BaseContextMenu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
</BaseContextMenu.SubmenuTrigger>
)
}
type ContextMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
}
export function ContextMenuSubContent({
children,
placement = 'right-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuSubContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
export function ContextMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
return (
<BaseContextMenu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
}
export function ContextMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
return (
<BaseContextMenu.Separator
className={cn(menuSeparatorClassName, className)}
{...props}
/>
)
}

View File

@ -1,13 +1,12 @@
import { Menu } from '@base-ui/react/menu'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import Link from 'next/link'
import { describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuLinkItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@ -15,18 +14,22 @@ import {
DropdownMenuTrigger,
} from '../index'
describe('dropdown-menu wrapper', () => {
describe('alias exports', () => {
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
})
})
vi.mock('next/link', () => ({
default: ({
href,
children,
...props
}: {
href: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
<a href={href} {...props}>
{children}
</a>
),
}))
describe('dropdown-menu wrapper', () => {
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => {
render(
@ -250,6 +253,99 @@ describe('dropdown-menu wrapper', () => {
})
})
describe('DropdownMenuLinkItem', () => {
it('should render as anchor and keep href/target attributes when link props are provided', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer">
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'Docs' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
it('should preserve link semantics when render prop uses a custom link component', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
render={<Link href="/account" />}
aria-label="account link"
>
Account settings
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'account link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', '/account')
expect(link).toHaveTextContent('Account settings')
})
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="docs link"
id={`menu-link-${String(destructive)}`}
onClick={handleClick}
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
fireEvent.click(link)
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
const handleMouseEnter = vi.fn()

View File

@ -8,6 +8,7 @@ import {
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
@ -234,6 +235,22 @@ export const WithIcons: Story = {
),
}
export const WithLinkItems: Story = {
render: () => (
<DropdownMenu>
<TriggerButton label="Open links" />
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</DropdownMenuLinkItem>
<DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
const ComplexDemo = () => {
const [sortOrder, setSortOrder] = useState('newest')
const [showArchived, setShowArchived] = useState(false)

View File

@ -3,6 +3,14 @@
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuRadioGroup = Menu.RadioGroup
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export function DropdownMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
className={cn(menuRowClassName, className)}
{...props}
/>
)
@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
className={cn(menuRowClassName, className)}
{...props}
/>
)
@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
className,
)}
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
@ -148,8 +136,8 @@ function renderDropdownMenuPopup({
>
<Menu.Popup
className={cn(
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@ -195,12 +183,7 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@ -253,12 +236,26 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof Menu.LinkItem> & {
destructive?: boolean
}
export function DropdownMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: DropdownMenuLinkItemProps) {
return (
<Menu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
)
@ -270,7 +267,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn('my-1 h-px bg-divider-subtle', className)}
className={cn(menuSeparatorClassName, className)}
{...props}
/>
)

View File

@ -0,0 +1,7 @@
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]'
export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'

View File

@ -225,5 +225,97 @@ describe('Compliance', () => {
payload: ACCOUNT_SETTING_TAB.BILLING,
})
})
// isPending branches: spinner visible, disabled class, guard blocks second call
it('should show spinner and guard against duplicate download when isPending is true', async () => {
// Arrange
let resolveDownload: (value: { url: string }) => void
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
resolveDownload = resolve
}))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
// Act
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
fireEvent.click(downloadButtons[0])
// Assert - btn-disabled class and spinner should appear while mutation is pending
await waitFor(() => {
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
expect(menuItem).not.toBeNull()
const disabledBtn = menuItem!.querySelector('.cursor-not-allowed')
expect(disabledBtn).not.toBeNull()
}, { timeout: 10000 })
// Cleanup: resolve the pending promise
resolveDownload!({ url: 'http://example.com/doc.pdf' })
await waitFor(() => {
expect(downloadUrl).toHaveBeenCalled()
})
})
it('should not call downloadCompliance again while pending', async () => {
let resolveDownload: (value: { url: string }) => void
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
resolveDownload = resolve
}))
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.team,
},
})
openMenuAndRender()
const downloadButtons = screen.getAllByText('common.operation.download')
// First click starts download
fireEvent.click(downloadButtons[0])
// Wait for mutation to start and React to re-render (isPending=true)
await waitFor(() => {
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
const el = menuItem!.querySelector('.cursor-not-allowed')
expect(el).not.toBeNull()
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })
// Second click while pending - should be guarded by isPending check
fireEvent.click(downloadButtons[0])
resolveDownload!({ url: 'http://example.com/doc.pdf' })
await waitFor(() => {
expect(downloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })
// getDocDownloadUrl should still have only been called once
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, 20000)
// canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent
it('should show upgrade badge with empty tooltip for enterprise plan', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.enterprise,
},
})
// Act
openMenuAndRender()
// Assert - enterprise is not in any download list, so upgrade badges should appear
// The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false
expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
})
})
})

View File

@ -184,7 +184,7 @@ export default function Compliance() {
<DropdownMenuSubContent
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
<DropdownMenuGroup className="py-1">
<ComplianceDocRowItem
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
label={t('compliance.soc2Type1', { ns: 'common' })}

View File

@ -247,6 +247,23 @@ describe('AccountDropdown', () => {
// Assert
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
})
// Compound AND middle-false: IS_CLOUD_EDITION=true but isCurrentWorkspaceOwner=false
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceOwner: false,
})
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument()
})
})
describe('Actions', () => {

View File

@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -41,12 +41,12 @@ function AccountMenuRouteItem({
trailing,
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<Link href={href} />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
</DropdownMenuLinkItem>
)
}
@ -64,12 +64,14 @@ function AccountMenuExternalItem({
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
href={href}
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
</DropdownMenuLinkItem>
)
}
@ -101,7 +103,7 @@ type AccountMenuSectionProps = {
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() {
@ -144,8 +146,8 @@ export default function AppSelector() {
sideOffset={6}
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="px-1 py-1">
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<DropdownMenuGroup className="py-1">
<div className="mx-1 flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="break-all text-text-primary system-md-medium">
{userProfile.name}

View File

@ -36,8 +36,8 @@ vi.mock('@/config', async (importOriginal) => {
return {
...actual,
IS_CE_EDITION: false,
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value },
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value || '' },
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value || '' },
}
})
@ -173,25 +173,18 @@ describe('Support', () => {
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
})
it('should show email support if specified in the config', () => {
// Optional chain null guard: ZENDESK_WIDGET_KEY is null
it('should show Email Support when ZENDESK_WIDGET_KEY is null', () => {
// Arrange
mockZendeskKey.value = ''
mockSupportEmailKey.value = 'support@example.com'
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
},
})
mockZendeskKey.value = null as unknown as string
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
// Assert
expect(screen.queryByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.emailSupport')?.closest('a')?.getAttribute('href')?.startsWith(`mailto:${mockSupportEmailKey.value}`)).toBe(true)
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
})
})

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
<DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
<DropdownMenuGroup className="py-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
@ -47,37 +47,43 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
</DropdownMenuItem>
)}
{hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} rel="noopener noreferrer" target="_blank" />}
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
</DropdownMenuLinkItem>
)}
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
href="https://forum.dify.ai/"
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-discuss-line"
label={t('userProfile.forum', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
<DropdownMenuItem
</DropdownMenuLinkItem>
<DropdownMenuLinkItem
className="justify-between"
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
href="https://discord.gg/5AEfbxcd9k"
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-discord-line"
label={t('userProfile.community', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
</DropdownMenuLinkItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>

View File

@ -136,4 +136,32 @@ describe('WorkplaceSelector', () => {
})
})
})
describe('Edge Cases', () => {
// find() returns undefined: no workspace with current: true
it('should not crash when no workspace has current: true', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
it('should not crash when workspace name is empty string', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
})
})

View File

@ -388,37 +388,33 @@ describe('DataSourceNotion Component', () => {
})
describe('Additional Action Edge Cases', () => {
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
it.each([
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
])('should cover connection data branch: %s', async (val) => {
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
render(<DataSourceNotion />)
const connectionCases = [
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
]
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
for (const val of connectionCases) {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
}
await waitFor(() => expect(useNotionConnection).toHaveBeenCalled())
expect(useNotionConnection).toHaveBeenCalled()
})
})

View File

@ -134,5 +134,46 @@ describe('ConfigJinaReaderModal Component', () => {
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should show encryption info and external link in the modal', async () => {
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Verify PKCS1_OAEP link exists
const pkcsLink = screen.getByText('PKCS1_OAEP')
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
// Verify the Jina Reader external link
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
expect(jinaLink).toHaveAttribute('target', '_blank')
})
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
const user = userEvent.setup()
// Arrange - a save that never resolves so isSaving stays true
let resolveFirst: (value: { result: 'success' }) => void
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
resolveFirst = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
await user.type(apiKeyInput, 'valid-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// First click - starts saving, isSaving becomes true
await user.click(saveBtn)
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Second click using fireEvent bypasses disabled check - hits isSaving guard
const { fireEvent: fe } = await import('@testing-library/react')
fe.click(saveBtn)
// Still only called once because isSaving=true returns early
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveFirst!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
})
})
})

View File

@ -195,4 +195,57 @@ describe('DataSourceWebsite Component', () => {
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Firecrawl Save Flow', () => {
it('should re-fetch sources after saving Firecrawl configuration', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
vi.mocked(fetchDataSources).mockClear()
// Act - fill in required API key field and save
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
})
})
})
describe('Cancel Flow', () => {
it('should close watercrawl modal when cancel is clicked', async () => {
// Arrange
await renderAndWait(DataSourceProvider.waterCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert - modal closed
await waitFor(() => {
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
})
})
it('should close jina reader modal when cancel is clicked', async () => {
// Arrange
await renderAndWait(DataSourceProvider.jinaReader)
fireEvent.click(screen.getByText('common.dataSource.configure'))
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert - modal closed
await waitFor(() => {
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
})
})
})
})

View File

@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Operate from './Operate'
describe('Operate', () => {
it('renders cancel and save when editing', () => {
it('should render cancel and save when editing is open', () => {
render(
<Operate
isOpen
@ -18,7 +19,7 @@ describe('Operate', () => {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('shows add key prompt when closed', () => {
it('should show add-key prompt when closed', () => {
render(
<Operate
isOpen={false}
@ -33,7 +34,7 @@ describe('Operate', () => {
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('shows invalid state indicator and edit prompt when status is fail', () => {
it('should show invalid state and edit prompt when status is fail', () => {
render(
<Operate
isOpen={false}
@ -49,7 +50,7 @@ describe('Operate', () => {
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('shows edit prompt without error text when status is success', () => {
it('should show edit prompt without error text when status is success', () => {
render(
<Operate
isOpen={false}
@ -65,11 +66,30 @@ describe('Operate', () => {
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
})
it('shows no actions for unsupported status', () => {
it('should not call onAdd when disabled', async () => {
const user = userEvent.setup()
const onAdd = vi.fn()
render(
<Operate
isOpen={false}
status={'unknown' as never}
status="add"
disabled
onAdd={onAdd}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
await user.click(screen.getByText('common.provider.addKey'))
expect(onAdd).not.toHaveBeenCalled()
})
it('should show no actions when status is unsupported', () => {
render(
<Operate
isOpen={false}
// @ts-expect-error intentional invalid status for runtime fallback coverage
status="unknown"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}

View File

@ -267,6 +267,99 @@ describe('MembersPage', () => {
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
})
it('should show non-billing member format for team plan even when billing is enabled', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.team,
total: { teamMembers: 50 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
})
it('should show invite button when user is manager but not owner', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
render(<MembersPage />)
expect(screen.getByRole('button', { name: /invite/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
})
it('should use created_at as fallback when last_active_at is empty', () => {
const memberNoLastActive: Member = {
...mockAccounts[1],
last_active_at: '',
created_at: '1700000000',
}
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [memberNoLastActive] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
})
it('should not show plural s when only one account in billing layout', () => {
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0]] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.sandbox,
total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should not show plural s when only one account in non-billing layout', () => {
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0]] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should show normal role as fallback for unknown role', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should show upgrade button when member limit is full', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,

View File

@ -1,5 +1,5 @@
import type { InvitationResponse } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
@ -171,6 +171,66 @@ describe('InviteModal', () => {
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
})
it('should show unlimited label when workspace member limit is zero', async () => {
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 5, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument()
})
it('should initialize usedSize to zero when workspace_members.size is null', async () => {
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: null, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
// usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0
expect(await screen.findByText('0')).toBeInTheDocument()
})
it('should not call onSend when invite result is not success', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'error',
invitation_results: [],
} as unknown as InvitationResponse)
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
})
})
it('should show destructive text color when used size exceeds limit', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
expect(counter.closest('div')).toHaveClass('text-text-destructive')
})
it('should not submit if already submitting', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
@ -202,4 +262,72 @@ describe('InviteModal', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
const user = userEvent.setup()
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
expect(sendBtn).toBeDisabled()
expect(inviteMember).not.toHaveBeenCalled()
})
it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
const invitePromise = new Promise<InvitationResponse>((resolve) => {
resolveInvite = resolve
})
vi.mocked(inviteMember).mockReturnValue(invitePromise)
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
// First click starts submission
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend
fireEvent.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Cleanup
resolveInvite!({ result: 'success', invitation_results: [] })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should not show error text color when isLimited is false even with many emails', async () => {
// size=0, limit=0 → isLimited=false, usedSize=emails.length
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 0, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
// isLimited=false → no destructive color
const counter = screen.getByText('1')
expect(counter.closest('div')).not.toHaveClass('text-text-destructive')
})
})

View File

@ -2,8 +2,12 @@ import type { InvitationResult } from '@/models/common'
import { render, screen } from '@testing-library/react'
import InvitedModal from './index'
const mockConfigState = vi.hoisted(() => ({ isCeEdition: true }))
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
get IS_CE_EDITION() {
return mockConfigState.isCeEdition
},
}))
describe('InvitedModal', () => {
@ -13,6 +17,11 @@ describe('InvitedModal', () => {
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
]
beforeEach(() => {
vi.clearAllMocks()
mockConfigState.isCeEdition = true
})
it('should show success and failed invitation sections', async () => {
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
@ -21,4 +30,59 @@ describe('InvitedModal', () => {
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
})
it('should hide invitation link section when there are no successes', () => {
const failedOnly: InvitationResult[] = [
{ email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' },
]
render(<InvitedModal invitationResults={failedOnly} onCancel={mockOnCancel} />)
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
expect(screen.getByText(/members\.failedInvitationEmails/i)).toBeInTheDocument()
})
it('should hide failed section when there are only successes', () => {
const successOnly: InvitationResult[] = [
{ email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' },
]
render(<InvitedModal invitationResults={successOnly} onCancel={mockOnCancel} />)
expect(screen.getByText(/members\.invitationLink/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
})
it('should hide both sections when results are empty', () => {
render(<InvitedModal invitationResults={[]} onCancel={mockOnCancel} />)
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
})
})
describe('InvitedModal (non-CE edition)', () => {
const mockOnCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockConfigState.isCeEdition = false
})
afterEach(() => {
mockConfigState.isCeEdition = true
})
it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => {
const results: InvitationResult[] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
]
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
// The !IS_CE_EDITION branch - should show the tip text
expect(await screen.findByText(/members\.invitationSentTip/i)).toBeInTheDocument()
// CE-only content should not be shown
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
})
})

View File

@ -49,13 +49,13 @@ describe('Operation', () => {
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
})
it('renders the current role label', () => {
it('should render the current role label when member has editor role', () => {
renderOperation()
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
})
it('shows dataset operator option when the feature flag is enabled', async () => {
it('should show dataset operator option when feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
@ -66,7 +66,7 @@ describe('Operation', () => {
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('shows owner-allowed role options for admin operators', async () => {
it('should show owner-allowed role options when operator role is admin', async () => {
const user = userEvent.setup()
renderOperation({}, 'admin')
@ -77,7 +77,7 @@ describe('Operation', () => {
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('does not show role options for unsupported operators', async () => {
it('should not show role options when operator role is unsupported', async () => {
const user = userEvent.setup()
renderOperation({}, 'normal')
@ -88,7 +88,7 @@ describe('Operation', () => {
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
})
it('calls updateMemberRole and onOperate when selecting another role', async () => {
it('should call updateMemberRole and onOperate when selecting another role', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
@ -102,7 +102,24 @@ describe('Operation', () => {
})
})
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
it('should show dataset operator option when operator is admin and feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation({}, 'admin')
await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
})
it('should fall back to normal role label when member role is unknown', () => {
renderOperation({ role: 'unknown_role' as Member['role'] })
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('should call deleteMemberOrCancelInvitation when removing the member', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)

View File

@ -13,11 +13,6 @@ vi.mock('@/context/app-context')
vi.mock('@/service/common')
vi.mock('@/service/use-common')
// Mock Modal directly to avoid transition/portal issues in tests
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ? <div data-testid="mock-modal">{children}</div> : null,
}))
vi.mock('./member-selector', () => ({
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
@ -40,11 +35,13 @@ describe('TransferOwnershipModal', () => {
data: { accounts: [] },
} as unknown as ReturnType<typeof useMembers>)
// Fix Location stubbing for reload
// Stub globalThis.location.reload (component calls globalThis.location.reload())
const mockReload = vi.fn()
vi.stubGlobal('location', {
...window.location,
reload: mockReload,
href: '',
assign: vi.fn(),
replace: vi.fn(),
} as unknown as Location)
})
@ -105,8 +102,8 @@ describe('TransferOwnershipModal', () => {
await waitFor(() => {
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
expect(window.location.reload).toHaveBeenCalled()
})
})
}, { timeout: 10000 })
}, 15000)
it('should handle timer countdown and resend', async () => {
vi.useFakeTimers()
@ -202,6 +199,70 @@ describe('TransferOwnershipModal', () => {
})
})
it('should handle sendOwnerEmail returning null data', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: null,
result: 'success',
} as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
renderModal()
await user.click(screen.getByTestId('transfer-modal-send-code'))
// Should advance to verify step even with null data
await waitFor(() => {
expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument()
})
})
it('should show fallback error prefix when sendOwnerEmail throws null', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockRejectedValue(null)
renderModal()
await user.click(screen.getByTestId('transfer-modal-send-code'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error sending verification code:'),
}))
})
})
it('should show fallback error prefix when verifyOwnerEmail throws null', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(verifyOwnerEmail).mockRejectedValue(null)
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error verifying email:'),
}))
})
})
it('should show fallback error prefix when ownershipTransfer throws null', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockRejectedValue(null)
renderModal()
await goToTransferStep(user)
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('Error ownership transfer:'),
}))
})
})
it('should close when close button is clicked', async () => {
const user = userEvent.setup()
renderModal()

View File

@ -71,9 +71,80 @@ describe('MemberSelector', () => {
})
})
it('should filter list by email when name does not match', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'john@')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument()
})
it('should show placeholder when value does not match any account', () => {
render(<MemberSelector value="nonexistent-id" onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should handle missing data gracefully', () => {
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should filter by email when account name is empty', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] },
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'noname@')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
})
it('should apply hover background class when dropdown is open', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
const trigger = screen.getByTestId('member-selector-trigger')
await user.click(trigger)
expect(trigger).toHaveClass('bg-state-base-hover-alt')
})
it('should not match account when neither name nor email contains search value', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'xyz-no-match-xyz')
expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument()
})
it('should fall back to empty string for account with undefined email when searching', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: {
accounts: [
{ id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' },
],
},
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'john')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
})
})

View File

@ -430,6 +430,55 @@ describe('hooks', () => {
expect(result.current.credentials).toBeUndefined()
})
it('should not call invalidateQueries when neither predefined nor custom is enabled', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useQuery as Mock).mockReturnValue({
data: undefined,
isPending: false,
})
// Both predefinedEnabled and customEnabled are false (no credentialId)
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
false,
undefined,
undefined,
))
act(() => {
result.current.mutate()
})
expect(invalidateQueries).not.toHaveBeenCalled()
})
it('should build URL without credentialId when not provided in predefined queryFn', async () => {
// Trigger the queryFn when credentialId is undefined but predefinedEnabled is true
; (useQuery as Mock).mockReturnValue({
data: { credentials: { api_key: 'k' } },
isPending: false,
})
const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
undefined,
))
// Find and invoke the predefined queryFn
const queryCall = (useQuery as Mock).mock.calls.find(
call => call[0].queryKey?.[1] === 'credentials',
)
if (queryCall) {
await queryCall[0].queryFn()
expect(fetchModelProviderCredentials).toHaveBeenCalled()
}
})
})
describe('useModelList', () => {
@ -1140,6 +1189,26 @@ describe('hooks', () => {
expect(result.current.plugins![0].plugin_id).toBe('plugin1')
})
it('should deduplicate plugins that exist in both collections and regular plugins', () => {
const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' }
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [duplicatePlugin],
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }],
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1)
})
it('should handle loading states', () => {
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
@ -1156,6 +1225,45 @@ describe('hooks', () => {
expect(result.current.isLoading).toBe(true)
})
it('should not crash when plugins is undefined', () => {
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: [],
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: undefined,
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
expect(result.current.plugins).toBeDefined()
expect(result.current.isLoading).toBe(false)
})
it('should return search plugins (not allPlugins) when searchText is truthy', () => {
const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }]
const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }]
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
plugins: collectionPlugins,
isLoading: false,
})
; (useMarketplacePlugins as Mock).mockReturnValue({
plugins: searchPlugins,
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
isLoading: false,
})
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
expect(result.current.plugins).toEqual(searchPlugins)
expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false)
})
})
describe('useRefreshModel', () => {
@ -1313,6 +1421,27 @@ describe('hooks', () => {
})
})
it('should invalidate all supported model types when __model_type is undefined', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const provider = createMockProvider()
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider, customFields, true)
})
// When __model_type is undefined, all supported model types are invalidated.
const modelListCalls = invalidateQueries.mock.calls.filter(
call => call[0]?.queryKey?.[0] === 'model-list',
)
expect(modelListCalls).toHaveLength(provider.supported_model_types.length)
})
it('should handle provider with single model type', () => {
const invalidateQueries = vi.fn()

View File

@ -96,4 +96,97 @@ describe('AddCredentialInLoadBalancing', () => {
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
})
// renderTrigger with open=true: bg-state-base-hover style applied
it('should apply hover background when trigger is rendered with open=true', async () => {
vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
Authorized: ({
renderTrigger,
}: {
renderTrigger: (open?: boolean) => React.ReactNode
}) => (
<div data-testid="open-trigger">{renderTrigger(true)}</div>
),
}))
// Must invalidate module cache so the component picks up the new mock
vi.resetModules()
try {
const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing')
const { container } = render(
<AddCredentialLB
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
// The trigger div rendered by renderTrigger(true) should have bg-state-base-hover
// (the static class applied when open=true via cn())
const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div')
expect(triggerDiv).toBeInTheDocument()
expect(triggerDiv!.className).toContain('bg-state-base-hover')
}
finally {
vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth')
vi.resetModules()
}
})
// customizableModel configuration method: component renders the add credential label
it('should render correctly with customizableModel configuration method', () => {
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.customizableModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should handle undefined available_credentials gracefully using nullish coalescing', () => {
const credentialWithNoAvailable = {
available_credentials: undefined,
credentials: {},
load_balancing: { enabled: false, configs: [] },
} as unknown as typeof modelCredential
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={credentialWithNoAvailable}
onSelectCredential={vi.fn()}
/>,
)
// Component should render without error - the ?? [] fallback is used
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should not throw when update action fires without onUpdate prop', () => {
// Arrange - no onUpdate prop
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
// Act - trigger the update without onUpdate being set (should not throw)
expect(() => {
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
}).not.toThrow()
})
})

View File

@ -86,4 +86,69 @@ describe('CredentialItem', () => {
expect(onDelete).not.toHaveBeenCalled()
})
// All disable flags true → no action buttons rendered
it('should hide all action buttons when disableRename, disableEdit, and disableDelete are all true', () => {
// Act
render(
<CredentialItem
credential={credential}
onEdit={vi.fn()}
onDelete={vi.fn()}
disableRename
disableEdit
disableDelete
/>,
)
// Assert
expect(screen.queryByTestId('edit-icon')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-icon')).not.toBeInTheDocument()
})
// disabled=true guards: clicks on the item row and on delete should both be no-ops
it('should not call onItemClick when disabled=true and item is clicked', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={credential} disabled onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onDelete when disabled=true and delete button is clicked', () => {
const onDelete = vi.fn()
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onDelete).not.toHaveBeenCalled()
})
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
render(
<CredentialItem
credential={credential}
showSelectedIcon
selectedCredentialId="cred-1"
/>,
)
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
})
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
render(
<CredentialItem
credential={credential}
showSelectedIcon
selectedCredentialId="other-cred"
/>,
)
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
})
})

View File

@ -24,36 +24,6 @@ vi.mock('../hooks', () => ({
}),
}))
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpen = open
return <div data-testid="portal" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content">{children}</div>
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
)
},
}))
vi.mock('./authorized-item', () => ({
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
credentials: Credential[]
@ -105,382 +75,127 @@ describe('Authorized', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockDeleteCredentialId = null
mockDoingAction = false
})
describe('Rendering', () => {
it('should render trigger button', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
it('should render trigger and open popup when trigger is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /addApiKey/i })).toBeInTheDocument()
})
it('should render portal content when open', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
it('should call handleOpenModal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(mockHandleOpenModal).toHaveBeenCalled()
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
it('should not render portal content when closed', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
/>,
)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
it('should render Add API Key button when not model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
})
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
it('should render Add Model Credential button when is model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
isOpen
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
expect(screen.getByText(/addModelCredential/)).toBeInTheDocument()
})
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
isOpen
/>,
)
it('should call handleOpenModal with fixed model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(screen.getByText(/addModelCredential/))
it('should render popup title when provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
popupTitle="Select Credential"
isOpen
/>,
)
expect(screen.getByText('Select Credential')).toBeInTheDocument()
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
describe('User Interactions', () => {
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
const onOpenChange = vi.fn()
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
/>,
)
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen={false}
onOpenChange={onOpenChange}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(onOpenChange).toHaveBeenCalledWith(true)
})
it('should toggle portal on trigger click', () => {
const { rerender } = render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
rerender(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should open modal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal when Add API Key is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addApiKey/))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal with credential and model when edit is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Edit')[0])
expect(mockHandleOpenModal).toHaveBeenCalledWith(
mockCredentials[0],
mockItems[0].model,
)
})
it('should pass current model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addModelCredential/))
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should not call onItemClick when disableItemClick is true', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
disableItemClick
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).not.toHaveBeenCalled()
})
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
})
describe('Delete Confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
it('should show confirm dialog and call confirm handler when delete is confirmed', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
it('should call closeConfirmDelete when cancel is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockCloseConfirmDelete).toHaveBeenCalled()
})
it('should call handleConfirmDelete when confirm is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Confirm'))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty items array', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={[]}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
it('should not render add action when provider does not allow custom token', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<Authorized
provider={restrictedProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i }))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
})
})

View File

@ -1,5 +1,6 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigProvider from './config-provider'
const mockUseCredentialStatus = vi.fn()
@ -54,7 +55,8 @@ describe('ConfigProvider', () => {
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should still render setup label when custom credentials are not allowed', () => {
it('should show setup label and unavailable tooltip when custom credentials are not allowed and no credential exists', async () => {
const user = userEvent.setup()
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
@ -65,6 +67,50 @@ describe('ConfigProvider', () => {
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
await user.hover(screen.getByText(/operation.setup/i))
expect(await screen.findByText(/auth\.credentialUnavailable/i)).toBeInTheDocument()
})
it('should show config label when hasCredential but not authorized', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: false,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should show config label when custom credentials are not allowed but credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: true,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should handle nullish credential values with fallbacks', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
current_credential_id: null,
current_credential_name: null,
available_credentials: null,
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
})

View File

@ -1,12 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CredentialSelector from './credential-selector'
// Mock components
vi.mock('./authorized/credential-item', () => ({
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => (
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => (
<button type="button" onClick={() => onItemClick?.(credential)}>
{credential.credential_name}
</div>
</button>
),
}))
@ -19,22 +19,6 @@ vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
// Mock portal components
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
// We should only render children if open or if we want to test they are hidden
// The real component might handle this with CSS or conditional rendering.
// Let's use conditional rendering in the mock to avoid "multiple elements" errors.
return <div data-testid="portal-content">{children}</div>
},
}))
describe('CredentialSelector', () => {
const mockCredentials = [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
@ -46,7 +30,7 @@ describe('CredentialSelector', () => {
vi.clearAllMocks()
})
it('should render selected credential name', () => {
it('should render selected credential name when selectedCredential is provided', () => {
render(
<CredentialSelector
selectedCredential={mockCredentials[0]}
@ -55,12 +39,11 @@ describe('CredentialSelector', () => {
/>,
)
// Use getAllByText and take the first one (the one in the trigger)
expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toBeInTheDocument()
})
it('should render placeholder when no credential selected', () => {
it('should render placeholder when selectedCredential is missing', () => {
render(
<CredentialSelector
credentials={mockCredentials}
@ -71,7 +54,8 @@ describe('CredentialSelector', () => {
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
})
it('should open portal on click', () => {
it('should call onSelect when a credential item is clicked', async () => {
const user = userEvent.setup()
render(
<CredentialSelector
credentials={mockCredentials}
@ -79,26 +63,14 @@ describe('CredentialSelector', () => {
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getAllByTestId('credential-item')).toHaveLength(2)
})
it('should call onSelect when a credential is clicked', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('Key 2'))
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
})
it('should call onSelect with add new credential data when clicking add button', () => {
it('should call onSelect with add-new payload when add action is clicked', async () => {
const user = userEvent.setup()
render(
<CredentialSelector
credentials={mockCredentials}
@ -106,8 +78,8 @@ describe('CredentialSelector', () => {
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
credential_id: '__add_new_credential',
@ -115,7 +87,8 @@ describe('CredentialSelector', () => {
}))
})
it('should not open portal when disabled', () => {
it('should not open options when disabled is true', async () => {
const user = userEvent.setup()
render(
<CredentialSelector
disabled
@ -124,7 +97,7 @@ describe('CredentialSelector', () => {
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument()
})
})

View File

@ -1,9 +1,11 @@
import type { ReactNode } from 'react'
import type {
Credential,
CustomModel,
ModelProvider,
} from '../../declarations'
import { act, renderHook } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
import { useAuth } from './use-auth'
@ -20,9 +22,13 @@ const mockAddModelCredential = vi.fn()
const mockEditProviderCredential = vi.fn()
const mockEditModelCredential = vi.fn()
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({ notify: mockNotify }),
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelModalHandler: () => mockOpenModelModal,
@ -66,6 +72,12 @@ describe('useAuth', () => {
model_type: ModelTypeEnum.textGeneration,
}
const createWrapper = ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{children}
</ToastContext.Provider>
)
beforeEach(() => {
vi.clearAllMocks()
mockDeleteModelService.mockResolvedValue({ result: 'success' })
@ -80,7 +92,7 @@ describe('useAuth', () => {
})
it('should open and close delete confirmation state', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, model)
@ -100,7 +112,7 @@ describe('useAuth', () => {
})
it('should activate credential, notify success, and refresh models', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleActiveCredential(credential, model)
@ -120,7 +132,7 @@ describe('useAuth', () => {
})
it('should close delete dialog without calling services when nothing is pending', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleConfirmDelete()
@ -137,7 +149,7 @@ describe('useAuth', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
isModelCredential: false,
onRemove,
}))
}), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, model)
@ -161,7 +173,7 @@ describe('useAuth', () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
onRemove,
}))
}), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(undefined, model)
@ -179,7 +191,7 @@ describe('useAuth', () => {
})
it('should add or edit credentials and refresh on successful save', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'new-key' })
@ -200,7 +212,7 @@ describe('useAuth', () => {
const deferred = createDeferred<{ result: string }>()
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
let first!: Promise<void>
let second!: Promise<void>
@ -226,7 +238,7 @@ describe('useAuth', () => {
isModelCredential: true,
onUpdate,
mode: ModelModalModeEnum.configModelCredential,
}))
}), { wrapper: createWrapper })
act(() => {
result.current.handleOpenModal(credential, model)
@ -244,4 +256,90 @@ describe('useAuth', () => {
}),
)
})
it('should not notify or refresh when handleSaveCredential returns non-success result', async () => {
mockAddProviderCredential.mockResolvedValue({ result: 'error' })
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'some-key' })
})
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'some-key' })
expect(mockNotify).not.toHaveBeenCalled()
expect(mockHandleRefreshModel).not.toHaveBeenCalled()
})
it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
await act(async () => {
await result.current.handleActiveCredential(credential)
})
expect(mockActiveProviderCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: undefined,
model_type: undefined,
})
})
// openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null
it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, undefined)
})
expect(result.current.deleteCredentialId).toBe('cred-1')
expect(result.current.deleteModel).toBeNull()
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
expect(result.current.pendingOperationModel.current).toBeNull()
})
// doingActionRef guard: second handleConfirmDelete call while first is in progress is a no-op
it('should ignore a second handleConfirmDelete call while the first is still in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
act(() => {
result.current.openConfirmDelete(credential, model)
})
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleConfirmDelete()
second = result.current.handleConfirmDelete()
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockDeleteProviderCredential).toHaveBeenCalledTimes(1)
})
// doingActionRef guard: second handleActiveCredential call while first is in progress is a no-op
it('should ignore a second handleActiveCredential call while the first is still in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockActiveProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleActiveCredential(credential)
second = result.current.handleActiveCredential(credential)
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1)
})
})

View File

@ -13,11 +13,13 @@ vi.mock('./hooks', () => ({
// Mock Authorized
vi.mock('./authorized', () => ({
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-container">{renderTrigger()}</div>
<div data-testid="trigger-closed">{renderTrigger()}</div>
<div data-testid="trigger-open">{renderTrigger(true)}</div>
<div data-testid="popup-title">{popupTitle}</div>
<div data-testid="items-count">{items.length}</div>
<div data-testid="items-selected">{items.map((it, i) => <span key={i} data-testid={`selected-${i}`}>{it.selectedCredential ? 'has-cred' : 'no-cred'}</span>)}</div>
</div>
),
}))
@ -55,8 +57,41 @@ describe('ManageCustomModelCredentials', () => {
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0)
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
})
it('should render trigger in both open and closed states', () => {
const mockModels = [
{
model: 'gpt-4',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('trigger-closed')).toBeInTheDocument()
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
})
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
const mockModels = [
{
model: 'gpt-3.5',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: '',
current_credential_name: '',
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('selected-0')).toHaveTextContent('no-cred')
})
})

View File

@ -18,15 +18,6 @@ vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
@ -125,6 +116,131 @@ describe('SwitchCredentialInLoadBalancing', () => {
/>,
)
fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/))
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
})
// Empty credentials with allowed custom: no tooltip but still shows unavailable text
it('should show unavailable status without tooltip when custom credentials are allowed', () => {
// Act
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// Assert
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
})
// not_allowed_to_use=true: indicator is red and destructive button text is shown
it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => {
const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[unavailableCredential]}
customModelCredential={unavailableCredential}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
})
// from_enterprise=true on the selected credential: Enterprise badge appears in the trigger
it('should show Enterprise badge when selected credential has from_enterprise=true', () => {
const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[enterpriseCredential]}
customModelCredential={enterpriseCredential}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
// non-empty credentials with allow_custom_token=false: no tooltip (tooltip only for empty+notAllowCustom)
it('should not show unavailable tooltip when credentials are non-empty and allow_custom_token=false', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<SwitchCredentialInLoadBalancing
provider={restrictedProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
fireEvent.mouseEnter(screen.getByText('Key 1'))
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should pass undefined currentCustomConfigurationModelFixedFields when model is undefined', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
// @ts-expect-error testing runtime handling when model is omitted
model={undefined}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// Component still renders (Authorized receives undefined currentCustomConfigurationModelFixedFields)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should treat undefined credentials as empty list', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={undefined}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// credentials is undefined → empty=true → unavailable text shown
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
})
it('should render nothing for credential_name when it is empty string', () => {
const credWithEmptyName = { credential_id: 'cred-1', credential_name: '' }
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[credWithEmptyName]}
customModelCredential={credWithEmptyName}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
// indicator-green shown (not authRemoved, not unavailable, not empty)
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
// credential_name is empty so nothing printed for name
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
})

View File

@ -24,10 +24,6 @@ vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
}))
const createI18nText = (value: string): I18nText => ({
en_US: value,
zh_Hans: value,
@ -92,10 +88,10 @@ describe('ModelIcon', () => {
icon_small: createI18nText('openai.png'),
})
render(<ModelIcon provider={provider} modelName="o1" />)
const { container } = render(<ModelIcon provider={provider} modelName="o1" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
// Edge case
@ -105,4 +101,25 @@ describe('ModelIcon', () => {
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.firstChild).not.toBeNull()
})
it('should render OpenAI Yellow icon for langgenius/openai/openai provider with model starting with o', () => {
const provider = createModel({
provider: 'langgenius/openai/openai',
icon_small: createI18nText('openai.png'),
})
const { container } = render(<ModelIcon provider={provider} modelName="o3" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should apply opacity-50 when isDeprecated is true', () => {
const provider = createModel()
const { container } = render(<ModelIcon provider={provider} isDeprecated={true} />)
const wrapper = container.querySelector('.opacity-50')
expect(wrapper).toBeInTheDocument()
})
})

View File

@ -161,7 +161,7 @@ function Form<
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@ -204,13 +204,14 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
)}
{tooltipContent}
</div>
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
{options.filter((option) => {
if (option.show_on.length)
@ -229,7 +230,7 @@ function Form<
>
<RadioE isChecked={value[variable] === option.value} />
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
<div className="text-text-secondary system-sm-regular">{option.label[language] || option.label.en_US}</div>
</div>
))}
</div>
@ -254,7 +255,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
@ -295,9 +296,9 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className="system-sm-semibold flex items-center justify-between py-2 text-text-secondary">
<div className="flex items-center justify-between py-2 text-text-secondary system-sm-semibold">
<div className="flex items-center space-x-2">
<span className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>{label[language] || label.en_US}</span>
<span className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>{label[language] || label.en_US}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
)}
@ -326,7 +327,7 @@ function Form<
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@ -358,7 +359,7 @@ function Form<
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@ -422,7 +423,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>
@ -451,7 +452,7 @@ function Form<
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
{label[language] || label.en_US}
{required && (
<span className="ml-1 text-red-500">*</span>

View File

@ -93,4 +93,88 @@ describe('Input', () => {
expect(onChange).not.toHaveBeenCalledWith('2')
expect(onChange).not.toHaveBeenCalledWith('6')
})
it('should not clamp when min and max are not provided', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Free"
onChange={onChange}
/>,
)
const input = screen.getByPlaceholderText('Free')
fireEvent.change(input, { target: { value: '999' } })
fireEvent.blur(input)
// onChange only called from change event, not from blur clamping
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('999')
})
it('should show check circle icon when validated is true', () => {
const { container } = render(
<Input
placeholder="Key"
onChange={vi.fn()}
validated
/>,
)
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument()
})
it('should not show check circle icon when validated is false', () => {
const { container } = render(
<Input
placeholder="Key"
onChange={vi.fn()}
validated={false}
/>,
)
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument()
})
it('should apply disabled attribute when disabled prop is true', () => {
render(
<Input
placeholder="Disabled"
onChange={vi.fn()}
disabled
/>,
)
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled()
})
it('should call onFocus when input receives focus', () => {
const onFocus = vi.fn()
render(
<Input
placeholder="Focus"
onChange={vi.fn()}
onFocus={onFocus}
/>,
)
fireEvent.focus(screen.getByPlaceholderText('Focus'))
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should render with custom className', () => {
render(
<Input
placeholder="Styled"
onChange={vi.fn()}
className="custom-class"
/>,
)
expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class')
})
})

View File

@ -1,9 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelParameterModal from './index'
let isAPIKeySet = true
let parameterRules = [
let parameterRules: Array<Record<string, unknown>> | undefined = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
@ -62,42 +61,17 @@ vi.mock('../hooks', () => ({
}),
}))
// Mock PortalToFollowElem components to control visibility and simplify testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div data-testid="portal-wrapper">
{children}
</div>
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}
})
vi.mock('./parameter-item', () => ({
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
default: ({ parameterRule, onChange, onSwitch }: {
parameterRule: { name: string, label: { en_US: string } }
onChange: (v: number) => void
onSwitch: (checked: boolean, val: unknown) => void
}) => (
<div data-testid={`param-${parameterRule.name}`}>
{parameterRule.label.en_US}
<input
aria-label={parameterRule.name}
value={value || ''}
onChange={e => onChange(Number(e.target.value))}
/>
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
<button onClick={() => onChange(0.9)}>Change</button>
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
<button onClick={() => onSwitch(true, 'assigned')}>Add</button>
</div>
),
}))
@ -105,7 +79,6 @@ vi.mock('./parameter-item', () => ({
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
Model Selector
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
</div>
),
@ -121,16 +94,11 @@ vi.mock('./trigger', () => ({
default: () => <button>Open Settings</button>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
}))
// Mock config
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
PROVIDER_WITH_PRESET_TONE: ['openai'],
}
})
@ -188,21 +156,19 @@ describe('ModelParameterModal', () => {
]
})
it('should render trigger and content', () => {
it('should render trigger and open modal content when trigger is clicked', () => {
render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByText('Open Settings')).toBeInTheDocument()
expect(screen.getByText('Temperature')).toBeInTheDocument()
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
})
it('should update params when changed and handle switch add/remove', () => {
it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
const input = screen.getByLabelText('temperature')
fireEvent.change(input, { target: { value: '0.9' } })
fireEvent.click(screen.getByText('Change'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.9,
@ -218,51 +184,18 @@ describe('ModelParameterModal', () => {
})
})
it('should handle preset selection', () => {
it('should call onCompletionParamsChange when preset is selected', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
})
it('should handle debug mode toggle', () => {
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
const toggle = screen.getByText(/debugAsMultipleModel/i)
fireEvent.click(toggle)
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should handle custom renderTrigger', () => {
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
expect(renderTrigger).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(renderTrigger).toHaveBeenCalledTimes(1)
})
it('should handle model selection and advanced mode parameters', () => {
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
it('should call setModel when model selector picks another model', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Select GPT-4.1'))
expect(defaultProps.setModel).toHaveBeenCalledWith({
modelId: 'gpt-4.1',
provider: 'openai',
@ -270,4 +203,32 @@ describe('ModelParameterModal', () => {
features: ['vision', 'tool-call'],
})
})
it('should toggle debug mode when debug footer is clicked', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText(/debugAsMultipleModel/i))
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
})
it('should render loading state when parameter rules are loading', () => {
isRulesLoading = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not open content when readonly is true', () => {
render(<ModelParameterModal {...defaultProps} readonly />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
it('should render no parameter items when rules are undefined', () => {
parameterRules = undefined
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

View File

@ -1,238 +1,182 @@
import type { ModelParameterRule } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/radio', () => {
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
<div>
{children}
<button onClick={() => onChange(true)}>Select True</button>
<button onClick={() => onChange(false)}>Select False</button>
</div>
)
return { default: Radio }
})
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
<select onChange={e => onSelect({ value: e.target.value })}>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
vi.mock('@/app/components/base/slider', () => ({
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
<button onClick={() => onChange(!value)}>Switch</button>
default: ({ onChange }: { onChange: (v: number) => void }) => (
<button onClick={() => onChange(2)} data-testid="slider-btn">Slide 2</button>
),
}))
vi.mock('@/app/components/base/tag-input', () => ({
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
<input onChange={e => onChange(e.target.value.split(','))} />
default: ({ onChange }: { onChange: (v: string[]) => void }) => (
<button onClick={() => onChange(['tag1', 'tag2'])} data-testid="tag-input">Tag</button>
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
type: 'float',
min: 0,
max: 1,
help: { en_US: 'Help text', zh_Hans: 'Help text' },
required: false,
...overrides,
})
const createProps = (overrides: {
parameterRule?: ModelParameterRule
value?: number | string | boolean | string[]
} = {}) => {
const onChange = vi.fn()
const onSwitch = vi.fn()
return {
parameterRule: createRule(),
value: 0.7,
onChange,
onSwitch,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render float input with slider', () => {
const props = createProps()
const { rerender } = render(<ParameterItem {...props} />)
expect(screen.getByText('Temperature')).toBeInTheDocument()
// Float tests
it('should render float controls and clamp numeric input to max', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.8' } })
expect(props.onChange).toHaveBeenCalledWith(0.8)
fireEvent.change(input, { target: { value: '1.4' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(input, { target: { value: '-0.2' } })
expect(props.onChange).toHaveBeenCalledWith(0)
const slider = screen.getByRole('slider')
fireEvent.change(slider, { target: { value: '2' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(slider, { target: { value: '-1' } })
expect(props.onChange).toHaveBeenCalledWith(0)
fireEvent.change(slider, { target: { value: '0.4' } })
expect(props.onChange).toHaveBeenCalledWith(0.4)
fireEvent.blur(input)
expect(input).toHaveValue(0.7)
const minBoundedProps = createProps({
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
value: 1.5,
})
rerender(<ParameterItem {...minBoundedProps} />)
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
expect(onChange).toHaveBeenCalledWith(1)
expect(screen.getByTestId('slider-btn')).toBeInTheDocument()
})
it('should render boolean radio', () => {
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
render(<ParameterItem {...props} />)
it('should clamp float numeric input to min', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0.1, max: 1 })} value={0.7} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.05' } })
expect(onChange).toHaveBeenCalledWith(0.1)
})
// Int tests
it('should render int controls and clamp numeric input', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '15' } })
expect(onChange).toHaveBeenCalledWith(10)
fireEvent.change(input, { target: { value: '-5' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('should adjust step based on max for int type', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 50 })} value={5} />)
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1')
rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 500 })} value={50} />)
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '10')
rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 2000 })} value={50} />)
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '100')
})
it('should render int input without slider if min or max is missing', () => {
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
// No max -> precision step
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
})
// Slider events (uses generic value mock for slider)
it('should handle slide change and clamp values', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
// Test that the actual slider triggers the onChange logic correctly
// The implementation of Slider uses onChange(val) directly via the mock
fireEvent.click(screen.getByTestId('slider-btn'))
expect(onChange).toHaveBeenCalledWith(2)
})
// Text & String tests
it('should render exact string input and propagate text changes', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } })
expect(onChange).toHaveBeenCalledWith('updated')
})
it('should render textarea for text type', () => {
const onChange = vi.fn()
const { container } = render(<ParameterItem parameterRule={createRule({ type: 'text' })} value="long text" onChange={onChange} />)
const textarea = container.querySelector('textarea')!
expect(textarea).toBeInTheDocument()
fireEvent.change(textarea, { target: { value: 'new long text' } })
expect(onChange).toHaveBeenCalledWith('new long text')
})
it('should render select for string with options', () => {
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
// SimpleSelect renders an element with text 'a'
expect(screen.getByText('a')).toBeInTheDocument()
})
// Tag Tests
it('should render tag input for tag type', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
expect(screen.getByText('placeholder')).toBeInTheDocument()
// Trigger mock tag input
fireEvent.click(screen.getByTestId('tag-input'))
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
})
// Boolean tests
it('should render boolean radios and update value on click', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
fireEvent.click(screen.getByText('False'))
expect(onChange).toHaveBeenCalledWith(false)
})
// Switch tests
it('should call onSwitch with current value when optional switch is toggled off', () => {
const onSwitch = vi.fn()
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
fireEvent.click(screen.getByRole('switch'))
expect(onSwitch).toHaveBeenCalledWith(false, 0.7)
})
it('should not render switch if required or name is stop', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ required: true as unknown as false })} value={1} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
rerender(<ParameterItem parameterRule={createRule({ name: 'stop', required: false })} value={1} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
// Default Value Fallbacks (rendering without value)
it('should use default values if value is undefined', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
rerender(<ParameterItem parameterRule={createRule({ type: 'string', default: 'hello' })} />)
expect(screen.getByRole('textbox')).toHaveValue('hello')
rerender(<ParameterItem parameterRule={createRule({ type: 'boolean', default: true })} />)
expect(screen.getByText('True')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select False'))
expect(props.onChange).toHaveBeenCalledWith(false)
expect(screen.getByText('False')).toBeInTheDocument()
// Without default
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
expect(screen.getByRole('spinbutton')).toHaveValue(0)
})
it('should render string input and select options', () => {
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
const { rerender } = render(<ParameterItem {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new' } })
expect(props.onChange).toHaveBeenCalledWith('new')
const selectProps = createProps({
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
value: 'opt1',
})
rerender(<ParameterItem {...selectProps} />)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: 'opt2' } })
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
// Input Blur
it('should reset input to actual bound value on blur', () => {
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
const input = screen.getByRole('spinbutton')
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
// Actually our test fires a change so localValue = 1, then blur sets it
fireEvent.change(input, { target: { value: '5' } })
fireEvent.blur(input)
expect(input).toHaveValue(1)
})
it('should handle switch toggle', () => {
const props = createProps()
let view = render(<ParameterItem {...props} />)
fireEvent.click(screen.getByText('Switch'))
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
const intDefaultProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...intDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
const stringDefaultProps = createProps({
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...stringDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
const booleanDefaultProps = createProps({
parameterRule: createRule({ type: 'boolean', default: true }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...booleanDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
const tagDefaultProps = createProps({
parameterRule: createRule({ type: 'tag', default: ['one'] }),
value: undefined,
})
view.unmount()
const tagView = render(<ParameterItem {...tagDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
const zeroValueProps = createProps({
parameterRule: createRule({ type: 'float', default: 0.5 }),
value: 0,
})
tagView.unmount()
render(<ParameterItem {...zeroValueProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
})
it('should support text and tag parameter interactions', () => {
const textProps = createProps({
parameterRule: createRule({ type: 'text', name: 'prompt' }),
value: 'initial prompt',
})
const { rerender } = render(<ParameterItem {...textProps} />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
const tagProps = createProps({
parameterRule: createRule({
type: 'tag',
name: 'tags',
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
}),
value: ['alpha'],
})
rerender(<ParameterItem {...tagProps} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
})
it('should support int parameters and unknown type fallback', () => {
const intProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
value: 100,
})
const { rerender } = render(<ParameterItem {...intProps} />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
expect(intProps.onChange).toHaveBeenCalledWith(350)
const unknownTypeProps = createProps({
parameterRule: createRule({ type: 'unsupported' }),
value: 0.7,
})
rerender(<ParameterItem {...unknownTypeProps} />)
// Unsupported
it('should render no input for unsupported parameter type', () => {
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})

View File

@ -2,19 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import PresetsParameter from './presets-parameter'
vi.mock('@/app/components/base/dropdown', () => ({
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
<div>
{renderTrigger(false)}
{items.map(item => (
<button key={item.value} onClick={() => onSelect(item)}>
{item.text}
</button>
))}
</div>
),
}))
describe('PresetsParameter', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -26,7 +13,39 @@ describe('PresetsParameter', () => {
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
fireEvent.click(screen.getByText('common.model.tone.Creative'))
expect(onSelect).toHaveBeenCalledWith(1)
})
// open=true: trigger has bg-state-base-hover class
it('should apply hover background class when open is true', () => {
render(<PresetsParameter onSelect={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
const button = screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })
expect(button).toHaveClass('bg-state-base-hover')
})
// Tone map branch 2: Balanced → Scales02 icon
it('should call onSelect with tone id 2 when Balanced is clicked', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
fireEvent.click(screen.getByText('common.model.tone.Balanced'))
expect(onSelect).toHaveBeenCalledWith(2)
})
// Tone map branch 3: Precise → Target04 icon
it('should call onSelect with tone id 3 when Precise is clicked', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
fireEvent.click(screen.getByText('common.model.tone.Precise'))
expect(onSelect).toHaveBeenCalledWith(3)
})
})

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import StatusIndicators from './status-indicators'
@ -8,10 +9,6 @@ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
}))
@ -38,57 +35,95 @@ describe('StatusIndicators', () => {
expect(container).toBeEmptyDOMElement()
})
it('should render warning states when provider model is disabled', () => {
const parentClick = vi.fn()
const { rerender } = render(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
it('should render deprecated tooltip when provider model is disabled and in model list', async () => {
const user = userEvent.setup()
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
expect(parentClick).not.toHaveBeenCalled()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>
</div>,
expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
})
it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => {
const user = userEvent.setup()
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
})
it('should render switch plugin version when pluginInfo exists for disabled unsupported model', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>,
)
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
})
it('should render marketplace warning when provider is unavailable', () => {
it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => {
const { container } = render(
<StatusIndicators
needsConfiguration={true}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => {
installedPlugins = []
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'missing-plugin' }}
t={t}
/>,
)
expect(screen.getByText('SwitchVersion:')).toBeInTheDocument()
})
it('should render marketplace warning tooltip when provider is unavailable', async () => {
const user = userEvent.setup()
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
@ -98,6 +133,11 @@ describe('StatusIndicators', () => {
t={t}
/>,
)
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,6 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Trigger from './trigger'
vi.mock('../hooks', () => ({
@ -24,6 +25,10 @@ describe('Trigger', () => {
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
beforeEach(() => {
vi.clearAllMocks()
})
it('should render initialized state', () => {
render(
<Trigger
@ -44,4 +49,92 @@ describe('Trigger', () => {
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
// isInWorkflow=true: workflow border class + RiArrowDownSLine arrow
it('should render workflow styles when isInWorkflow is true', () => {
// Act
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
isInWorkflow
/>,
)
// Assert
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
expect(container.querySelectorAll('svg').length).toBe(2)
})
// disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip
it('should show deprecated warning when disabled with hasDeprecated', () => {
// Act
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
disabled
hasDeprecated
/>,
)
// Assert - AlertTriangle renders with warning color
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
})
// disabled=true + modelDisabled=true: status text tooltip
it('should show model status tooltip when disabled with modelDisabled', () => {
// Act
render(
<Trigger
currentProvider={currentProvider}
currentModel={{ ...currentModel, status: 'no-configure' } as unknown as typeof currentModel}
disabled
modelDisabled
/>,
)
// Assert - AlertTriangle warning icon should be present
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
})
it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => {
const user = userEvent.setup()
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
disabled
hasDeprecated={false}
modelDisabled={false}
/>,
)
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
const tooltip = screen.queryByRole('tooltip')
if (tooltip)
expect(tooltip).toBeEmptyDOMElement()
expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument()
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
})
// providerName not matching any provider: find() returns undefined
it('should render without crashing when providerName does not match any provider', () => {
// Act
render(
<Trigger
modelId="gpt-4"
providerName="unknown-provider"
/>,
)
// Assert
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
})

View File

@ -10,4 +10,22 @@ describe('EmptyTrigger', () => {
render(<EmptyTrigger open={false} />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
// open=true: hover bg class present
it('should apply hover background class when open is true', () => {
// Act
const { container } = render(<EmptyTrigger open={true} />)
// Assert
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
})
// className prop truthy: custom className appears on root
it('should apply custom className when provided', () => {
// Act
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@ -13,6 +13,7 @@ function createWrapper() {
}
let mockModelLoadBalancingEnabled = false
let mockPlanType: string = 'pro'
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
@ -22,7 +23,7 @@ vi.mock('@/context/app-context', () => ({
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: 'pro' },
plan: { type: mockPlanType },
}),
useProviderContextSelector: () => mockModelLoadBalancingEnabled,
}))
@ -68,6 +69,7 @@ describe('ModelListItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelLoadBalancingEnabled = false
mockPlanType = 'pro'
})
it('should render model item with icon and name', () => {
@ -139,4 +141,127 @@ describe('ModelListItem', () => {
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
expect(onModifyLoadBalancing).toHaveBeenCalledWith(mockModel)
})
// Deprecated branches: opacity-60, disabled switch, no ConfigModel
it('should show deprecated model with opacity and disabled switch', () => {
// Arrange
const deprecatedModel = { ...mockModel, deprecated: true } as unknown as ModelItem
mockModelLoadBalancingEnabled = true
// Act
const { container } = render(
<ModelListItem
model={deprecatedModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert
expect(container.querySelector('.opacity-60')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
})
// Load balancing badge: visible when all 4 conditions met
it('should show load balancing badge when all conditions are met', () => {
// Arrange
mockModelLoadBalancingEnabled = true
const lbModel = {
...mockModel,
load_balancing_enabled: true,
has_invalid_load_balancing_configs: false,
deprecated: false,
} as unknown as ModelItem
// Act
render(
<ModelListItem
model={lbModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - Badge component should render
const badge = document.querySelector('.border-text-accent-secondary')
expect(badge).toBeInTheDocument()
})
// Plan.sandbox: ConfigModel shown without load balancing enabled
it('should show ConfigModel for sandbox plan even without load balancing enabled', () => {
// Arrange - set plan type to sandbox and keep load balancing disabled
mockModelLoadBalancingEnabled = false
mockPlanType = 'sandbox'
// Act
render(
<ModelListItem
model={mockModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - ConfigModel should show because plan.type === 'sandbox'
expect(screen.getByRole('button', { name: 'modify load balancing' })).toBeInTheDocument()
})
// Negative proof: non-sandbox plan without load balancing should NOT show ConfigModel
it('should hide ConfigModel for non-sandbox plan without load balancing enabled', () => {
// Arrange - set plan type to non-sandbox and keep load balancing disabled
mockModelLoadBalancingEnabled = false
mockPlanType = 'pro'
// Act
render(
<ModelListItem
model={mockModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - ConfigModel should NOT show because plan.type !== 'sandbox' and load balancing is disabled
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
})
// model.status=credentialRemoved: switch disabled, no ConfigModel
it('should disable switch and hide ConfigModel when status is credentialRemoved', () => {
// Arrange
const removedModel = { ...mockModel, status: ModelStatusEnum.credentialRemoved } as unknown as ModelItem
mockModelLoadBalancingEnabled = true
// Act
render(
<ModelListItem
model={removedModel}
provider={mockProvider}
isConfigurable={false}
/>,
)
// Assert - ConfigModel should not render because status is not active/disabled
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
const statusSwitch = screen.getByRole('switch')
expect(statusSwitch).toHaveClass('!cursor-not-allowed')
fireEvent.click(statusSwitch)
expect(statusSwitch).toHaveAttribute('aria-checked', 'false')
expect(enableModel).not.toHaveBeenCalled()
expect(disableModel).not.toHaveBeenCalled()
})
// isConfigurable=true: hover class on row
it('should apply hover class when isConfigurable is true', () => {
// Act
const { container } = render(
<ModelListItem
model={mockModel}
provider={mockProvider}
isConfigurable={true}
/>,
)
// Assert
expect(container.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,6 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum } from '../declarations'
import ModelList from './model-list'
const mockSetShowModelLoadBalancingModal = vi.fn()
@ -105,4 +106,120 @@ describe('ModelList', () => {
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
// isConfigurable=false: predefinedModel only provider hides custom model actions
it('should hide custom model actions when provider uses predefinedModel only', () => {
// Arrange
const predefinedProvider = {
provider: 'test-provider',
configurate_methods: ['predefinedModel'],
} as unknown as ModelProvider
// Act
render(
<ModelList
provider={predefinedProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
it('should call onSave (onChange) and onClose from the load balancing modal callbacks', () => {
render(
<ModelList
provider={mockProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'gpt-4' }))
expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalled()
const callArg = mockSetShowModelLoadBalancingModal.mock.calls[0][0]
callArg.onSave('test-provider')
expect(mockOnChange).toHaveBeenCalledWith('test-provider')
callArg.onClose()
expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalledWith(null)
})
// fetchFromRemote filtered out: provider with only fetchFromRemote
it('should hide custom model actions when provider uses fetchFromRemote only', () => {
// Arrange
const fetchOnlyProvider = {
provider: 'test-provider',
configurate_methods: ['fetchFromRemote'],
} as unknown as ModelProvider
// Act
render(
<ModelList
provider={fetchOnlyProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
it('should show custom model actions when provider is configurable and user is workspace manager', () => {
// Arrange: use ConfigurationMethodEnum.customizableModel ('customizable-model') so isConfigurable=true
const configurableProvider = {
provider: 'test-provider',
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = true
// Act
render(
<ModelList
provider={configurableProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert: custom model actions are shown (isConfigurable=true && isCurrentWorkspaceManager=true)
expect(screen.getByTestId('manage-credentials')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
})
it('should hide custom model actions when provider is configurable but user is not workspace manager', () => {
// Arrange: use ConfigurationMethodEnum.customizableModel ('customizable-model') so isConfigurable=true, but manager=false
const configurableProvider = {
provider: 'test-provider',
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = false
// Act
render(
<ModelList
provider={configurableProvider}
models={mockModels}
onCollapse={mockOnCollapse}
onChange={mockOnChange}
/>,
)
// Assert: custom model actions are hidden (isCurrentWorkspaceManager=false covers the && short-circuit)
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
})
})

View File

@ -5,7 +5,7 @@ import type {
ModelLoadBalancingConfig,
ModelProvider,
} from '../declarations'
import { act, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
@ -261,6 +261,128 @@ describe('ModelLoadBalancingConfigs', () => {
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
})
it('should remove credential at index 0', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
// Create config where the target credential is at index 0
const config: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-target', credential_id: 'cred-2', enabled: true, name: 'Key 2' },
{ id: 'cfg-other', credential_id: 'cred-1', enabled: true, name: 'Key 1' },
],
} as ModelLoadBalancingConfig
render(<StatefulHarness initialConfig={config} onRemove={onRemove} />)
await user.click(screen.getByRole('button', { name: 'trigger remove' }))
expect(onRemove).toHaveBeenCalledWith('cred-2')
expect(screen.queryByText('Key 2')).not.toBeInTheDocument()
})
it('should not toggle load balancing when modelLoadBalancingEnabled=false and enabling via switch', async () => {
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch />)
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
await user.click(mainSwitch)
// Switch is disabled so toggling to true should not work
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
})
it('should toggle load balancing to false when modelLoadBalancingEnabled=false but enabled=true via switch', async () => {
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
// When draftConfig.enabled=true and !enabled (toggling off): condition `(modelLoadBalancingEnabled || !enabled)` = (!enabled) = true
render(<StatefulHarness initialConfig={createDraftConfig(true)} withSwitch />)
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
await user.click(mainSwitch)
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
it('should not show provider badge when isProviderManaged=true but configurationMethod is customizableModel', () => {
const inheritConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' },
],
} as ModelLoadBalancingConfig
render(
<StatefulHarness
initialConfig={inheritConfig}
configurationMethod={ConfigurationMethodEnum.customizableModel}
/>,
)
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.providerManaged')).not.toBeInTheDocument()
})
it('should show upgrade panel when modelLoadBalancingEnabled=false and not CE edition', () => {
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} />)
expect(screen.getByText('upgrade')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.upgradeForLoadBalancing')).toBeInTheDocument()
})
it('should pass explicit boolean state to toggleConfigEntryEnabled (typeof state === boolean branch)', async () => {
// Arrange: render with a config entry; the Switch onChange passes explicit boolean value
const user = userEvent.setup()
render(<StatefulHarness initialConfig={createDraftConfig(true)} />)
// Act: click the switch which calls toggleConfigEntryEnabled(index, value) where value is boolean
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-1')
await user.click(entrySwitch)
// Assert: component still renders after the toggle (state = explicit boolean true/false)
expect(screen.getByTestId('load-balancing-main-panel')).toBeInTheDocument()
})
it('should render with credential that has not_allowed_to_use flag (covers credential?.not_allowed_to_use ? false branch)', () => {
// Arrange: config where the credential is not allowed to use
const restrictedConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-restricted', credential_id: 'cred-restricted', enabled: true, name: 'Restricted Key' },
],
} as ModelLoadBalancingConfig
const mockModelCredentialWithRestricted = {
available_credentials: [
{
credential_id: 'cred-restricted',
credential_name: 'Restricted Key',
not_allowed_to_use: true,
},
],
} as unknown as ModelCredential
// Act
render(
<ModelLoadBalancingConfigs
draftConfig={restrictedConfig}
setDraftConfig={vi.fn()}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={mockModelCredentialWithRestricted}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
/>,
)
// Assert: Switch value should be false (credential?.not_allowed_to_use ? false branch)
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-restricted')
expect(entrySwitch).toHaveAttribute('aria-checked', 'false')
})
it('should handle edge cases where draftConfig becomes null during callbacks', async () => {
let capturedAdd: ((credential: Credential) => void) | null = null
let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = null
@ -298,4 +420,82 @@ describe('ModelLoadBalancingConfigs', () => {
// Should not throw and just return prev (which is undefined)
})
it('should not toggle load balancing when modelLoadBalancingEnabled=false and clicking panel to enable', async () => {
// Arrange: load balancing not enabled in context, draftConfig.enabled=false (so panel is clickable)
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch={false} />)
// Act: clicking the panel calls toggleModalBalancing(true)
// but (modelLoadBalancingEnabled || !enabled) = (false || false) = false → condition fails
const panel = screen.getByTestId('load-balancing-main-panel')
await user.click(panel)
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
it('should return early from addConfigEntry setDraftConfig when prev is undefined', async () => {
// Arrange: use a controlled wrapper that exposes a way to force draftConfig to undefined
let capturedAdd: ((credential: Credential) => void) | null = null
const MockChild = ({ onSelectCredential }: {
onSelectCredential: (credential: Credential) => void
}) => {
capturedAdd = onSelectCredential
return null
}
vi.mocked(AddCredentialInLoadBalancing).mockImplementation(MockChild as unknown as typeof AddCredentialInLoadBalancing)
// Use a setDraftConfig spy that tracks calls and simulates null prev
const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => {
if (typeof updater === 'function')
updater(undefined)
})
render(
<ModelLoadBalancingConfigs
draftConfig={createDraftConfig(true)}
setDraftConfig={setDraftConfigSpy}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={mockModelCredential}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
/>,
)
// Act: trigger addConfigEntry with undefined prev via the spy
act(() => {
if (capturedAdd)
(capturedAdd as (credential: Credential) => void)({ credential_id: 'new', credential_name: 'New' } as Credential)
})
// Assert: setDraftConfig was called and the updater returned early (prev was undefined)
expect(setDraftConfigSpy).toHaveBeenCalled()
})
it('should return early from updateConfigEntry setDraftConfig when prev is undefined', async () => {
// Arrange: use setDraftConfig spy that invokes updater with undefined prev
const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => {
if (typeof updater === 'function')
updater(undefined)
})
render(
<ModelLoadBalancingConfigs
draftConfig={createDraftConfig(true)}
setDraftConfig={setDraftConfigSpy}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={mockModelCredential}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
/>,
)
// Act: click remove button which triggers updateConfigEntry → setDraftConfig with prev=undefined
const removeBtn = screen.getByTestId('load-balancing-remove-cfg-1')
fireEvent.click(removeBtn)
// Assert: setDraftConfig was called and handled undefined prev gracefully
expect(setDraftConfigSpy).toHaveBeenCalled()
})
})

View File

@ -130,7 +130,7 @@ const ModelLoadBalancingConfigs = ({
const handleRemove = useCallback((credentialId: string) => {
const index = draftConfig?.configs.findIndex(item => item.credential_id === credentialId && item.name !== '__inherit__')
if (index && index > -1)
if (typeof index === 'number' && index > -1)
updateConfigEntry(index, () => undefined)
onRemove?.(credentialId)
}, [draftConfig?.configs, updateConfigEntry, onRemove])

View File

@ -1,8 +1,18 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ToastContext } from '@/app/components/base/toast/context'
import { ConfigurationMethodEnum } from '../declarations'
import ModelLoadBalancingModal from './model-load-balancing-modal'
vi.mock('@headlessui/react', () => ({
Transition: ({ show, children }: { show: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null),
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogPanel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>,
DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => <h3 className={className}>{children}</h3>,
}))
type CredentialData = {
load_balancing: {
enabled: boolean
@ -43,11 +53,15 @@ let mockCredentialData: CredentialData | undefined = {
current_credential_name: 'Default',
}
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
}),
}
})
vi.mock('@/service/use-models', () => ({
useGetModelCredential: () => ({
@ -102,6 +116,8 @@ vi.mock('../model-name', () => ({
}))
describe('ModelLoadBalancingModal', () => {
let user: ReturnType<typeof userEvent.setup>
const mockProvider = {
provider: 'test-provider',
provider_credential_schema: {
@ -118,8 +134,15 @@ describe('ModelLoadBalancingModal', () => {
fetch_from: 'predefined-model',
} as unknown as ModelItem
const renderModal = (node: Parameters<typeof render>[0]) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{node}
</ToastContext.Provider>,
)
beforeEach(() => {
vi.clearAllMocks()
user = userEvent.setup()
mockDeleteModel = null
mockCredentialData = {
load_balancing: {
@ -143,7 +166,7 @@ describe('ModelLoadBalancingModal', () => {
it('should show loading area while draft config is not ready', () => {
mockCredentialData = undefined
render(
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
@ -156,7 +179,7 @@ describe('ModelLoadBalancingModal', () => {
})
it('should render predefined model content', () => {
render(
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
@ -173,7 +196,7 @@ describe('ModelLoadBalancingModal', () => {
it('should render custom model actions and close when update has no credentials', async () => {
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
render(
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
@ -185,7 +208,7 @@ describe('ModelLoadBalancingModal', () => {
expect(screen.getByText(/modelProvider\.auth\.removeModel/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'config add credential' }))
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
@ -195,7 +218,7 @@ describe('ModelLoadBalancingModal', () => {
const onSave = vi.fn()
const onClose = vi.fn()
render(
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
@ -206,9 +229,9 @@ describe('ModelLoadBalancingModal', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'config add credential' }))
fireEvent.click(screen.getByRole('button', { name: 'config rename credential' }))
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
@ -226,7 +249,7 @@ describe('ModelLoadBalancingModal', () => {
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
render(
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
@ -236,7 +259,7 @@ describe('ModelLoadBalancingModal', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'switch credential' }))
await user.click(screen.getByRole('button', { name: 'switch credential' }))
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
@ -246,7 +269,7 @@ describe('ModelLoadBalancingModal', () => {
const onClose = vi.fn()
mockDeleteModel = { model: 'gpt-4' }
render(
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
@ -256,8 +279,8 @@ describe('ModelLoadBalancingModal', () => {
/>,
)
fireEvent.click(screen.getByText(/modelProvider\.auth\.removeModel/))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await user.click(screen.getByText(/modelProvider\.auth\.removeModel/))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockOpenConfirmDelete).toHaveBeenCalled()
@ -265,4 +288,479 @@ describe('ModelLoadBalancingModal', () => {
expect(onClose).toHaveBeenCalled()
})
})
// Disabled load balancing: title shows configModel text
it('should show configModel title when load balancing is disabled', () => {
mockCredentialData = {
...mockCredentialData!,
load_balancing: {
enabled: false,
configs: mockCredentialData!.load_balancing.configs,
},
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
expect(screen.getByText(/modelProvider\.auth\.configModel/)).toBeInTheDocument()
})
// Modal hidden when open=false
it('should not render modal content when open is false', () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open={false}
/>,
)
expect(screen.queryByText(/modelProvider\.auth\.configLoadBalancing/)).not.toBeInTheDocument()
})
// Config rename: updates name in draft config
it('should rename credential in draft config', async () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
// Config remove: removes credential from draft
it('should remove credential from draft config', async () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'config remove' }))
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
// Save error: shows error toast
it('should show error toast when save fails', async () => {
mockMutateAsync.mockResolvedValue({ result: 'error' })
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
})
})
// No current_credential_id: modelCredential is undefined
it('should handle missing current_credential_id', () => {
mockCredentialData = {
...mockCredentialData!,
current_credential_id: '',
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
/>,
)
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
})
it('should disable save button when less than 2 configs are enabled', () => {
mockCredentialData = {
...mockCredentialData!,
load_balancing: {
enabled: true,
configs: [
{ id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Only One', credentials: { api_key: 'key' } },
{ id: 'cfg-2', credential_id: 'cred-2', enabled: false, name: 'Disabled', credentials: { api_key: 'key2' } },
],
},
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
expect(screen.getByText(/operation\.save/)).toBeDisabled()
})
it('should encode config entry without id as non-hidden value', async () => {
mockCredentialData = {
...mockCredentialData!,
load_balancing: {
enabled: true,
configs: [
{ id: '', credential_id: 'cred-new', enabled: true, name: 'New Entry', credentials: { api_key: 'new-key' } },
{ id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } },
],
},
}
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } }
// Entry without id should NOT be encoded as hidden
expect(payload.load_balancing.configs[0].credentials.api_key).toBe('new-key')
})
})
it('should add new credential to draft config when update finds matching credential', async () => {
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-new', credential_name: 'New Key' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
// Save after adding credential to verify it was added to draft
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should not update draft config when handleUpdate credential name does not match any available credential', async () => {
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-other', credential_name: 'Other Key' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
// "config add credential" triggers onUpdate(undefined, { __authorization_name__: 'New Key' })
// But refetch returns 'Other Key' not 'New Key', so find() returns undefined → no config update
await user.click(screen.getByRole('button', { name: 'config add credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
// The payload configs should only have the original 2 entries (no new one added)
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: unknown[] } }
expect(payload.load_balancing.configs).toHaveLength(2)
})
})
it('should toggle modal from enabled to disabled when clicking the card', async () => {
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
// draftConfig.enabled=true → title shows configLoadBalancing
expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument()
// Clicking the card when enabled=true toggles to disabled
const card = screen.getByText(/modelProvider\.auth\.providerManaged$/).closest('div[class]')!.closest('div[class]')!
await user.click(card)
// After toggling, title should show configModel (disabled state)
expect(screen.getByText(/modelProvider\.auth\.configModel/)).toBeInTheDocument()
})
it('should use customModelCredential credential_id when present in handleSave', async () => {
// Arrange: set up credential data so customModelCredential is initialized from current_credential_id
mockCredentialData = {
...mockCredentialData!,
current_credential_id: 'cred-1',
current_credential_name: 'Default',
}
const onSave = vi.fn()
const onClose = vi.fn()
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
onSave={onSave}
onClose={onClose}
credential={{ credential_id: 'cred-1', credential_name: 'Default' } as unknown as Parameters<typeof ModelLoadBalancingModal>[0]['credential']}
/>,
)
// Act: save triggers handleSave which uses customModelCredential?.credential_id
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
const payload = mockMutateAsync.mock.calls[0][0] as { credential_id: string }
// credential_id should come from customModelCredential
expect(payload.credential_id).toBe('cred-1')
})
})
it('should use null fallback for available_credentials when result.data is missing in handleUpdate', async () => {
// Arrange: refetch returns data without available_credentials
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: undefined })
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onClose={onClose}
/>,
)
// Act: trigger handleUpdate which does `result.data?.available_credentials || []`
await user.click(screen.getByRole('button', { name: 'config add credential' }))
// Assert: available_credentials falls back to [], so onClose is called
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
})
it('should use null fallback for available_credentials in handleUpdateWhenSwitchCredential when result.data is missing', async () => {
// Arrange: refetch returns data without available_credentials
const onClose = vi.fn()
mockRefetch.mockResolvedValue({ data: undefined })
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
onClose={onClose}
/>,
)
// Act: trigger handleUpdateWhenSwitchCredential which does `result.data?.available_credentials || []`
await user.click(screen.getByRole('button', { name: 'switch credential' }))
// Assert: available_credentials falls back to [], onClose is called
await waitFor(() => {
expect(onClose).toHaveBeenCalled()
})
})
it('should use predefined provider schema without fallback when credential_form_schemas is undefined', () => {
// Arrange: provider with no credential_form_schemas → triggers ?? [] fallback
const providerWithoutSchemas = {
provider: 'test-provider',
provider_credential_schema: {
credential_form_schemas: undefined,
},
model_credential_schema: {
credential_form_schemas: undefined,
},
} as unknown as ModelProvider
renderModal(
<ModelLoadBalancingModal
provider={providerWithoutSchemas}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
/>,
)
// Assert: component renders without error (extendedSecretFormSchemas = [])
expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument()
})
it('should use custom model credential schema without fallback when credential_form_schemas is undefined', () => {
// Arrange: provider with no model credential schemas → triggers ?? [] fallback for custom model path
const providerWithoutModelSchemas = {
provider: 'test-provider',
provider_credential_schema: {
credential_form_schemas: undefined,
},
model_credential_schema: {
credential_form_schemas: undefined,
},
} as unknown as ModelProvider
renderModal(
<ModelLoadBalancingModal
provider={providerWithoutModelSchemas}
configurateMethod={ConfigurationMethodEnum.customizableModel}
model={mockModel}
open
/>,
)
// Assert: component renders without error (extendedSecretFormSchemas = [])
expect(screen.getAllByText(/modelProvider\.auth\.specifyModelCredential/).length).toBeGreaterThan(0)
})
it('should not update draft config when rename finds no matching index in prevIndex', async () => {
// Arrange: credential in payload does not match any config (prevIndex = -1)
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-99', credential_name: 'Unknown' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
// Act: "config rename credential" triggers onUpdate with credential: { credential_id: 'cred-1' }
// but refetch returns cred-99, so newIndex for cred-1 is -1
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
// Save to verify the config was not changed
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: unknown[] } }
// Config count unchanged (still 2 from original)
expect(payload.load_balancing.configs).toHaveLength(2)
})
})
it('should encode credential_name as empty string when available_credentials has no name', async () => {
// Arrange: available_credentials has a credential with no credential_name
mockRefetch.mockResolvedValue({
data: {
available_credentials: [
{ credential_id: 'cred-1', credential_name: '' },
{ credential_id: 'cred-2', credential_name: 'Backup' },
],
},
})
renderModal(
<ModelLoadBalancingModal
provider={mockProvider}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
model={mockModel}
open
onSave={vi.fn()}
onClose={vi.fn()}
/>,
)
// Act: rename cred-1 which now has empty credential_name
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
await user.click(screen.getByText(/operation\.save/))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
})

View File

@ -163,6 +163,18 @@ const ModelLoadBalancingModal = ({
onSave?.(provider.provider)
onClose?.()
}
else {
notify({
type: 'error',
message: (res as { error?: string })?.error || t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }),
})
}
}
catch (error) {
notify({
type: 'error',
message: error instanceof Error ? error.message : t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }),
})
}
finally {
setLoading(false)
@ -218,7 +230,7 @@ const ModelLoadBalancingModal = ({
}
})
}
}, [refetch, credential])
}, [refetch, onClose])
const handleUpdateWhenSwitchCredential = useCallback(async () => {
const result = await refetch()

View File

@ -1,14 +1,45 @@
import { render } from '@testing-library/react'
import type { i18n } from 'i18next'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as reactI18next from 'react-i18next'
import PriorityUseTip from './priority-use-tip'
describe('PriorityUseTip', () => {
it('should render tooltip with icon content', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
it('should render tooltip with icon content', async () => {
const user = userEvent.setup()
const { container } = render(<PriorityUseTip />)
expect(container.querySelector('[data-state]')).toBeInTheDocument()
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(await screen.findByText('common.modelProvider.priorityUsing')).toBeInTheDocument()
})
it('should render the component without crashing', () => {
const { container } = render(<PriorityUseTip />)
expect(container.firstChild).toBeInTheDocument()
})
it('should exercise || fallback when t() returns empty string', async () => {
const user = userEvent.setup()
vi.spyOn(reactI18next, 'useTranslation').mockReturnValue({
t: () => '',
i18n: {} as unknown as i18n,
ready: true,
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
const { container } = render(<PriorityUseTip />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
expect(screen.queryByText('common.modelProvider.priorityUsing')).not.toBeInTheDocument()
expect(document.querySelector('.rounded-md.bg-components-panel-bg')).not.toBeInTheDocument()
})
})

View File

@ -33,7 +33,7 @@ vi.mock('@/service/common', () => ({
}))
describe('utils', () => {
afterEach(() => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -97,6 +97,18 @@ describe('utils', () => {
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' })
})
it('should return Unknown error when non-Error is thrown', async () => {
(validateModelProvider as unknown as Mock).mockRejectedValue('string error')
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
})
it('should return default error message when error field is empty', async () => {
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
})
})
describe('validateLoadBalancingCredentials', () => {
@ -140,6 +152,24 @@ describe('utils', () => {
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' })
})
it('should return Unknown error when non-Error is thrown', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(42)
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
})
it('should handle exception with Error', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(new Error('Timeout'))
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Timeout' })
})
it('should return default error message when error field is empty', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
})
})
describe('saveCredentials', () => {
@ -216,6 +246,19 @@ describe('utils', () => {
},
})
})
it('should remove predefined credentials without credentialId', async () => {
await removeCredentials(true, 'provider', {})
expect(deleteModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/credentials',
body: undefined,
})
})
it('should not call delete endpoint when non-predefined payload is falsy', async () => {
await removeCredentials(false, 'provider', null as unknown as Record<string, unknown>)
expect(deleteModelProvider).not.toHaveBeenCalled()
})
})
describe('genModelTypeFormSchema', () => {
@ -228,11 +271,22 @@ describe('utils', () => {
})
describe('genModelNameFormSchema', () => {
it('should generate form schema', () => {
it('should generate default form schema when no model provided', () => {
const schema = genModelNameFormSchema()
expect(schema.type).toBe(FormTypeEnum.textInput)
expect(schema.variable).toBe('__model_name')
expect(schema.required).toBe(true)
expect(schema.label.en_US).toBe('Model Name')
expect(schema.placeholder!.en_US).toBe('Please enter model name')
})
it('should use provided label and placeholder when model is given', () => {
const schema = genModelNameFormSchema({
label: { en_US: 'Custom', zh_Hans: 'Custom' },
placeholder: { en_US: 'Enter custom', zh_Hans: 'Enter custom' },
})
expect(schema.label.en_US).toBe('Custom')
expect(schema.placeholder!.en_US).toBe('Enter custom')
})
})
})

View File

@ -171,14 +171,15 @@ export const removeCredentials = async (predefined: boolean, provider: string, v
}
}
else {
if (v) {
const { __model_name, __model_type } = v
body = {
model: __model_name,
model_type: __model_type,
}
url = `/workspaces/current/model-providers/${provider}/models`
if (!v)
return
const { __model_name, __model_type } = v
body = {
model: __model_name,
model_type: __model_type,
}
url = `/workspaces/current/model-providers/${provider}/models`
}
return deleteModelProvider({ url, body })

View File

@ -20,9 +20,13 @@ const mockEventEmitter = vi.hoisted(() => {
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: vi.fn(),
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),

View File

@ -14,11 +14,15 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: vi.fn(),
}),
}
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({

View File

@ -264,4 +264,78 @@ describe('AppNav', () => {
await user.click(screen.getByTestId('load-more'))
expect(fetchNextPage).not.toHaveBeenCalled()
})
// Non-editor link path: isCurrentWorkspaceEditor=false → link ends with /overview
it('should build overview links when user is not editor', () => {
// Arrange
setupDefaultMocks({ isEditor: false })
// Act
render(<AppNav />)
// Assert
expect(screen.getByText('App 1 -> /app/app-1/overview')).toBeInTheDocument()
})
// !!appId false: query disabled, no nav items
it('should render no nav items when appId is undefined', () => {
// Arrange
setupDefaultMocks()
mockUseParams.mockReturnValue({} as ReturnType<typeof useParams>)
mockUseInfiniteAppList.mockReturnValue({
data: undefined,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useInfiniteAppList>)
// Act
render(<AppNav />)
// Assert
const navItems = screen.getByTestId('nav-items')
expect(navItems.children).toHaveLength(0)
})
// ADVANCED_CHAT OR branch: editor + ADVANCED_CHAT mode → link ends with /workflow
it('should build workflow link for ADVANCED_CHAT mode when user is editor', () => {
// Arrange
setupDefaultMocks({
isEditor: true,
appData: [
{
id: 'app-3',
name: 'Chat App',
mode: AppModeEnum.ADVANCED_CHAT,
icon_type: 'emoji',
icon: '💬',
icon_background: null,
icon_url: null,
},
],
})
// Act
render(<AppNav />)
// Assert
expect(screen.getByText('Chat App -> /app/app-3/workflow')).toBeInTheDocument()
})
// No-match update path: appDetail.id doesn't match any nav item
it('should not change nav item names when appDetail id does not match any item', async () => {
// Arrange
setupDefaultMocks({ isEditor: true })
const { rerender } = render(<AppNav />)
// Act - set appDetail to a non-matching id
mockAppDetail = { id: 'non-existent-id', name: 'Unknown' }
rerender(<AppNav />)
// Assert - original name should be unchanged
await waitFor(() => {
expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument()
})
})
})

View File

@ -6,10 +6,6 @@ function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
}
vi.mock('@/app/components/base/logo/dify-logo', () => ({
default: createMockComponent('dify-logo'),
}))
vi.mock('@/app/components/header/account-dropdown/workplace-selector', () => ({
default: createMockComponent('workplace-selector'),
}))
@ -129,7 +125,7 @@ describe('Header', () => {
it('should render header with main nav components', () => {
render(<Header />)
expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
@ -173,7 +169,7 @@ describe('Header', () => {
mockMedia = 'mobile'
render(<Header />)
expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
})
@ -186,6 +182,70 @@ describe('Header', () => {
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('dify-logo')).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
})
it('should show default Dify logo when branding is enabled but no workspace_logo', () => {
mockBrandingEnabled = true
mockBrandingTitle = 'Custom Title'
mockBrandingLogo = null
render(<Header />)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
})
it('should show default Dify text when branding enabled but no application_title', () => {
mockBrandingEnabled = true
mockBrandingTitle = null
mockBrandingLogo = null
render(<Header />)
expect(screen.getByText('Dify')).toBeInTheDocument()
})
it('should show dataset nav for editor who is not dataset operator', () => {
mockIsWorkspaceEditor = true
mockIsDatasetOperator = false
render(<Header />)
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
expect(screen.getByTestId('explore-nav')).toBeInTheDocument()
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
})
it('should hide dataset nav when neither editor nor dataset operator', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false
render(<Header />)
expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument()
})
it('should render mobile layout with dataset operator nav restrictions', () => {
mockMedia = 'mobile'
mockIsDatasetOperator = true
render(<Header />)
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('tools-nav')).not.toBeInTheDocument()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
})
it('should render mobile layout with billing enabled', () => {
mockMedia = 'mobile'
mockEnableBilling = true
mockPlanType = 'sandbox'
render(<Header />)
expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,61 @@
import { generateMailToLink, mailToSupport } from './util'
describe('generateMailToLink', () => {
// Email-only: both subject and body branches false
it('should return mailto link with email only when no subject or body provided', () => {
// Act
const result = generateMailToLink('test@example.com')
// Assert
expect(result).toBe('mailto:test@example.com')
})
// Subject provided, body not: subject branch true, body branch false
it('should append subject when subject is provided without body', () => {
// Act
const result = generateMailToLink('test@example.com', 'Hello World')
// Assert
expect(result).toBe('mailto:test@example.com?subject=Hello%20World')
})
// Body provided, no subject: subject branch false, body branch true
it('should append body with question mark when body is provided without subject', () => {
// Act
const result = generateMailToLink('test@example.com', undefined, 'Some body text')
// Assert
expect(result).toBe('mailto:test@example.com&body=Some%20body%20text')
})
// Both subject and body provided: both branches true
it('should append both subject and body when both are provided', () => {
// Act
const result = generateMailToLink('test@example.com', 'Subject', 'Body text')
// Assert
expect(result).toBe('mailto:test@example.com?subject=Subject&body=Body%20text')
})
})
describe('mailToSupport', () => {
// Transitive coverage: exercises generateMailToLink with all params
it('should generate a mailto link with support recipient, plan, account, and version info', () => {
// Act
const result = mailToSupport('user@test.com', 'Pro', '1.0.0')
// Assert
expect(result.startsWith('mailto:support@dify.ai?')).toBe(true)
const query = result.split('?')[1]
expect(query).toBeDefined()
const params = new URLSearchParams(query)
expect(params.get('subject')).toBe('Technical Support Request Pro user@test.com')
const body = params.get('body')
expect(body).toContain('Current Plan: Pro')
expect(body).toContain('Account: user@test.com')
expect(body).toContain('Version: 1.0.0')
})
})

View File

@ -16,6 +16,7 @@ This document tracks the migration away from legacy overlay APIs.
- Replacement primitives:
- `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu`
- `@/app/components/base/ui/context-menu`
- `@/app/components/base/ui/popover`
- `@/app/components/base/ui/dialog`
- `@/app/components/base/ui/alert-dialog`

View File

@ -4636,9 +4636,6 @@
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 10
},
"ts/no-explicit-any": {
"count": 6
}

View File

@ -69,13 +69,13 @@
"@formatjs/intl-localematcher": "0.5.10",
"@headlessui/react": "2.2.1",
"@heroicons/react": "2.2.0",
"@lexical/code": "0.38.2",
"@lexical/link": "0.38.2",
"@lexical/list": "0.38.2",
"@lexical/react": "0.38.2",
"@lexical/selection": "0.38.2",
"@lexical/text": "0.38.2",
"@lexical/utils": "0.39.0",
"@lexical/code": "0.41.0",
"@lexical/link": "0.41.0",
"@lexical/list": "0.41.0",
"@lexical/react": "0.41.0",
"@lexical/selection": "0.41.0",
"@lexical/text": "0.41.0",
"@lexical/utils": "0.41.0",
"@monaco-editor/react": "4.7.0",
"@octokit/core": "6.1.6",
"@octokit/request-error": "6.1.8",
@ -122,7 +122,7 @@
"katex": "0.16.25",
"ky": "1.12.0",
"lamejs": "1.2.1",
"lexical": "0.38.2",
"lexical": "0.41.0",
"mermaid": "11.11.0",
"mime": "4.1.0",
"mitt": "3.0.1",
@ -216,7 +216,7 @@
"@vitejs/plugin-react": "5.1.4",
"@vitejs/plugin-rsc": "0.5.21",
"@vitest/coverage-v8": "4.0.18",
"agentation": "2.2.1",
"agentation": "2.3.0",
"autoprefixer": "10.4.21",
"code-inspector-plugin": "1.4.2",
"cross-env": "10.1.0",
@ -243,7 +243,7 @@
"tsx": "4.21.0",
"typescript": "5.9.3",
"uglify-js": "3.19.3",
"vinext": "https://pkg.pr.new/hyoban/vinext@556a6d6",
"vinext": "https://pkg.pr.new/vinext@1a2fd61",
"vite": "8.0.0-beta.16",
"vite-plugin-inspect": "11.3.3",
"vite-tsconfig-paths": "6.1.1",
@ -252,6 +252,7 @@
},
"pnpm": {
"overrides": {
"@lexical/code": "npm:lexical-code-no-prism@0.41.0",
"@monaco-editor/loader": "1.5.0",
"@nolyfill/safe-buffer": "npm:safe-buffer@^5.2.1",
"@stylistic/eslint-plugin": "https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8",

435
web/pnpm-lock.yaml generated
View File

@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@lexical/code': npm:lexical-code-no-prism@0.41.0
'@monaco-editor/loader': 1.5.0
'@nolyfill/safe-buffer': npm:safe-buffer@^5.2.1
'@stylistic/eslint-plugin': https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8
@ -79,26 +80,26 @@ importers:
specifier: 2.2.0
version: 2.2.0(react@19.2.4)
'@lexical/code':
specifier: 0.38.2
version: 0.38.2
specifier: npm:lexical-code-no-prism@0.41.0
version: lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0)
'@lexical/link':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/list':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/react':
specifier: 0.38.2
version: 0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)
specifier: 0.41.0
version: 0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)
'@lexical/selection':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/text':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/utils':
specifier: 0.39.0
version: 0.39.0
specifier: 0.41.0
version: 0.41.0
'@monaco-editor/react':
specifier: 4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -238,8 +239,8 @@ importers:
specifier: 1.2.1
version: 1.2.1
lexical:
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
mermaid:
specifier: 11.11.0
version: 11.11.0
@ -515,8 +516,8 @@ importers:
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
agentation:
specifier: 2.2.1
version: 2.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 2.3.0
version: 2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
autoprefixer:
specifier: 10.4.21
version: 10.4.21(postcss@8.5.6)
@ -596,8 +597,8 @@ importers:
specifier: 3.19.3
version: 3.19.3
vinext:
specifier: https://pkg.pr.new/hyoban/vinext@556a6d6
version: https://pkg.pr.new/hyoban/vinext@556a6d6(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
specifier: https://pkg.pr.new/vinext@1a2fd61
version: https://pkg.pr.new/vinext@1a2fd61(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
vite:
specifier: 8.0.0-beta.16
version: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@ -1682,98 +1683,74 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lexical/clipboard@0.38.2':
resolution: {integrity: sha512-dDShUplCu8/o6BB9ousr3uFZ9bltR+HtleF/Tl8FXFNPpZ4AXhbLKUoJuucRuIr+zqT7RxEv/3M6pk/HEoE6NQ==}
'@lexical/clipboard@0.41.0':
resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}
'@lexical/clipboard@0.39.0':
resolution: {integrity: sha512-ylrHy8M+I5EH4utwqivslugqQhvgLTz9VEJdrb2RjbhKQEXwMcqKCRWh6cRfkYx64onE2YQE0nRIdzHhExEpLQ==}
'@lexical/code@0.38.2':
resolution: {integrity: sha512-wpqgbmPsfi/+8SYP0zI2kml09fGPRhzO5litR9DIbbSGvcbawMbRNcKLO81DaTbsJRnBJiQvbBBBJAwZKRqgBw==}
'@lexical/devtools-core@0.38.2':
resolution: {integrity: sha512-hlN0q7taHNzG47xKynQLCAFEPOL8l6IP79C2M18/FE1+htqNP35q4rWhYhsptGlKo4me4PtiME7mskvr7T4yqA==}
'@lexical/devtools-core@0.41.0':
resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
'@lexical/dragon@0.38.2':
resolution: {integrity: sha512-riOhgo+l4oN50RnLGhcqeUokVlMZRc+NDrxRNs2lyKSUdC4vAhAmAVUHDqYPyb4K4ZSw4ebZ3j8hI2zO4O3BbA==}
'@lexical/dragon@0.41.0':
resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==}
'@lexical/extension@0.38.2':
resolution: {integrity: sha512-qbUNxEVjAC0kxp7hEMTzktj0/51SyJoIJWK6Gm790b4yNBq82fEPkksfuLkRg9VQUteD0RT1Nkjy8pho8nNamw==}
'@lexical/extension@0.41.0':
resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==}
'@lexical/extension@0.39.0':
resolution: {integrity: sha512-mp/WcF8E53FWPiUHgHQz382J7u7C4+cELYNkC00dKaymf8NhS6M65Y8tyDikNGNUcLXSzaluwK0HkiKjTYGhVQ==}
'@lexical/hashtag@0.41.0':
resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==}
'@lexical/hashtag@0.38.2':
resolution: {integrity: sha512-jNI4Pv+plth39bjOeeQegMypkjDmoMWBMZtV0lCynBpkkPFlfMnyL9uzW/IxkZnX8LXWSw5mbWk07nqOUNTCrA==}
'@lexical/history@0.41.0':
resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==}
'@lexical/history@0.38.2':
resolution: {integrity: sha512-QWPwoVDMe/oJ0+TFhy78TDi7TWU/8bcDRFUNk1nWgbq7+2m+5MMoj90LmOFwakQHnCVovgba2qj+atZrab1dsQ==}
'@lexical/html@0.41.0':
resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==}
'@lexical/html@0.38.2':
resolution: {integrity: sha512-pC5AV+07bmHistRwgG3NJzBMlIzSdxYO6rJU4eBNzyR4becdiLsI4iuv+aY7PhfSv+SCs7QJ9oc4i5caq48Pkg==}
'@lexical/link@0.41.0':
resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==}
'@lexical/html@0.39.0':
resolution: {integrity: sha512-7VLWP5DpzBg3kKctpNK6PbhymKAtU6NAnKieopCfCIWlMW+EqpldteiIXGqSqrMRK0JWTmF1gKgr9nnQyOOsXw==}
'@lexical/list@0.41.0':
resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==}
'@lexical/link@0.38.2':
resolution: {integrity: sha512-UOKTyYqrdCR9+7GmH6ZVqJTmqYefKGMUHMGljyGks+OjOGZAQs78S1QgcPEqltDy+SSdPSYK7wAo6gjxZfEq9g==}
'@lexical/mark@0.41.0':
resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==}
'@lexical/list@0.38.2':
resolution: {integrity: sha512-OQm9TzatlMrDZGxMxbozZEHzMJhKxAbH1TOnOGyFfzpfjbnFK2y8oLeVsfQZfZRmiqQS4Qc/rpFnRP2Ax5dsbA==}
'@lexical/markdown@0.41.0':
resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==}
'@lexical/list@0.39.0':
resolution: {integrity: sha512-mxgSxUrakTCHtC+gF30BChQBJTsCMiMgfC2H5VvhcFwXMgsKE/aK9+a+C/sSvvzCmPXqzYsuAcGkJcrY3e5xlw==}
'@lexical/offset@0.41.0':
resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==}
'@lexical/mark@0.38.2':
resolution: {integrity: sha512-U+8KGwc3cP5DxSs15HfkP2YZJDs5wMbWQAwpGqep9bKphgxUgjPViKhdi+PxIt2QEzk7WcoZWUsK1d2ty/vSmg==}
'@lexical/overflow@0.41.0':
resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==}
'@lexical/markdown@0.38.2':
resolution: {integrity: sha512-ykQJ9KUpCs1+Ak6ZhQMP6Slai4/CxfLEGg/rSHNVGbcd7OaH/ICtZN5jOmIe9ExfXMWy1o8PyMu+oAM3+AWFgA==}
'@lexical/plain-text@0.41.0':
resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==}
'@lexical/offset@0.38.2':
resolution: {integrity: sha512-uDky2palcY+gE6WTv6q2umm2ioTUnVqcaWlEcchP6A310rI08n6rbpmkaLSIh3mT2GJQN2QcN2x0ct5BQmKIpA==}
'@lexical/overflow@0.38.2':
resolution: {integrity: sha512-f6vkTf+YZF0EuKvUK3goh4jrnF+Z0koiNMO+7rhSMLooc5IlD/4XXix4ZLiIktUWq4BhO84b82qtrO+6oPUxtw==}
'@lexical/plain-text@0.38.2':
resolution: {integrity: sha512-xRYNHJJFCbaQgr0uErW8Im2Phv1nWHIT4VSoAlBYqLuVGZBD4p61dqheBwqXWlGGJFk+MY5C5URLiMicgpol7A==}
'@lexical/react@0.38.2':
resolution: {integrity: sha512-M3z3MkWyw3Msg4Hojr5TnO4TzL71NVPVNGoavESjdgJbTdv1ezcQqjE4feq+qs7H9jytZeuK8wsEOJfSPmNd8w==}
'@lexical/react@0.41.0':
resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
'@lexical/rich-text@0.38.2':
resolution: {integrity: sha512-eFjeOT7YnDZYpty7Zlwlct0UxUSaYu53uLYG+Prs3NoKzsfEK7e7nYsy/BbQFfk5HoM1pYuYxFR2iIX62+YHGw==}
'@lexical/rich-text@0.41.0':
resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==}
'@lexical/selection@0.38.2':
resolution: {integrity: sha512-eMFiWlBH6bEX9U9sMJ6PXPxVXTrihQfFeiIlWLuTpEIDF2HRz7Uo1KFRC/yN6q0DQaj7d9NZYA6Mei5DoQuz5w==}
'@lexical/selection@0.41.0':
resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==}
'@lexical/selection@0.39.0':
resolution: {integrity: sha512-j0cgNuTKDCdf/4MzRnAUwEqG6C/WQp18k2WKmX5KIVZJlhnGIJmlgSBrxjo8AuZ16DIHxTm2XNB4cUDCgZNuPA==}
'@lexical/table@0.41.0':
resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==}
'@lexical/table@0.38.2':
resolution: {integrity: sha512-uu0i7yz0nbClmHOO5ZFsinRJE6vQnFz2YPblYHAlNigiBedhqMwSv5bedrzDq8nTTHwych3mC63tcyKIrM+I1g==}
'@lexical/text@0.41.0':
resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==}
'@lexical/table@0.39.0':
resolution: {integrity: sha512-1eH11kV4bJ0fufCYl8DpE19kHwqUI8Ev5CZwivfAtC3ntwyNkeEpjCc0pqeYYIWN/4rTZ5jgB3IJV4FntyfCzw==}
'@lexical/utils@0.41.0':
resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==}
'@lexical/text@0.38.2':
resolution: {integrity: sha512-+juZxUugtC4T37aE3P0l4I9tsWbogDUnTI/mgYk4Ht9g+gLJnhQkzSA8chIyfTxbj5i0A8yWrUUSw+/xA7lKUQ==}
'@lexical/utils@0.38.2':
resolution: {integrity: sha512-y+3rw15r4oAWIEXicUdNjfk8018dbKl7dWHqGHVEtqzAYefnEYdfD2FJ5KOTXfeoYfxi8yOW7FvzS4NZDi8Bfw==}
'@lexical/utils@0.39.0':
resolution: {integrity: sha512-8YChidpMJpwQc4nex29FKUeuZzC++QCS/Jt46lPuy1GS/BZQoPHFKQ5hyVvM9QVhc5CEs4WGNoaCZvZIVN8bQw==}
'@lexical/yjs@0.38.2':
resolution: {integrity: sha512-fg6ZHNrVQmy1AAxaTs8HrFbeNTJCaCoEDPi6pqypHQU3QVfqr4nq0L0EcHU/TRlR1CeduEPvZZIjUUxWTZ0u8g==}
'@lexical/yjs@0.41.0':
resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==}
peerDependencies:
yjs: '>=13.5.22'
@ -3705,8 +3682,8 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentation@2.2.1:
resolution: {integrity: sha512-yV9P1DggI7M3SRaRwLwt+xqE5lXqg5l8xtqCr8KzEkbnH8Wa6eRATU97uKnD7cC8FrsJP62Mmw0Xf5Xi5KV50Q==}
agentation@2.3.0:
resolution: {integrity: sha512-uGcDel78I5UAVSiWnsNv0pHj+ieuHyZ4GCsL6kqEralKeIW32869JlwfsKoy5S71jseyrI6O5duU+AacJs+CmQ==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
@ -5632,11 +5609,14 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lexical@0.38.2:
resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==}
lexical-code-no-prism@0.41.0:
resolution: {integrity: sha512-cFgCC/VMXjch58iod4TIhBHb1bx7Da8IdduUwltua581dhLmugcaFnUvgC0naBaPeYVuirA6cuDsyOdPgEEDLA==}
peerDependencies:
'@lexical/utils': '>=0.28.0'
lexical: '>=0.28.0'
lexical@0.39.0:
resolution: {integrity: sha512-lpLv7MEJH5QDujEDlYqettL3ATVtNYjqyimzqgrm0RvCm3AO9WXSdsgTxuN7IAZRu88xkxCDeYubeUf4mNZVdg==}
lexical@0.41.0:
resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==}
lib0@0.2.117:
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
@ -7524,8 +7504,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vinext@https://pkg.pr.new/hyoban/vinext@556a6d6:
resolution: {tarball: https://pkg.pr.new/hyoban/vinext@556a6d6}
vinext@https://pkg.pr.new/vinext@1a2fd61:
resolution: {integrity: sha512-5Q2iQExi1QQ/EpNcJ7TA6U9o4+kxJyaM/Ocobostt9IHqod6TOzhOx+ZSfmZr7eEVZq2joaIGY6Jl3dZ1dGNjg==, tarball: https://pkg.pr.new/vinext@1a2fd61}
version: 0.0.5
engines: {node: '>=22'}
hasBin: true
@ -9119,210 +9099,157 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lexical/clipboard@0.38.2':
'@lexical/clipboard@0.41.0':
dependencies:
'@lexical/html': 0.38.2
'@lexical/list': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/html': 0.41.0
'@lexical/list': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/clipboard@0.39.0':
'@lexical/devtools-core@0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@lexical/html': 0.39.0
'@lexical/list': 0.39.0
'@lexical/selection': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
'@lexical/code@0.38.2':
dependencies:
'@lexical/utils': 0.38.2
lexical: 0.38.2
prismjs: 1.30.0
'@lexical/devtools-core@0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@lexical/html': 0.38.2
'@lexical/link': 0.38.2
'@lexical/mark': 0.38.2
'@lexical/table': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/html': 0.41.0
'@lexical/link': 0.41.0
'@lexical/mark': 0.41.0
'@lexical/table': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@lexical/dragon@0.38.2':
'@lexical/dragon@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
lexical: 0.38.2
'@lexical/extension': 0.41.0
lexical: 0.41.0
'@lexical/extension@0.38.2':
'@lexical/extension@0.41.0':
dependencies:
'@lexical/utils': 0.38.2
'@lexical/utils': 0.41.0
'@preact/signals-core': 1.12.2
lexical: 0.38.2
lexical: 0.41.0
'@lexical/extension@0.39.0':
'@lexical/hashtag@0.41.0':
dependencies:
'@lexical/utils': 0.39.0
'@preact/signals-core': 1.12.2
lexical: 0.39.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/hashtag@0.38.2':
'@lexical/history@0.41.0':
dependencies:
'@lexical/text': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/history@0.38.2':
'@lexical/html@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/html@0.38.2':
'@lexical/link@0.41.0':
dependencies:
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/html@0.39.0':
'@lexical/list@0.41.0':
dependencies:
'@lexical/selection': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
'@lexical/extension': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/link@0.38.2':
'@lexical/mark@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/list@0.38.2':
'@lexical/markdown@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/code': lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0)
'@lexical/link': 0.41.0
'@lexical/list': 0.41.0
'@lexical/rich-text': 0.41.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/list@0.39.0':
'@lexical/offset@0.41.0':
dependencies:
'@lexical/extension': 0.39.0
'@lexical/selection': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
lexical: 0.41.0
'@lexical/mark@0.38.2':
'@lexical/overflow@0.41.0':
dependencies:
'@lexical/utils': 0.38.2
lexical: 0.38.2
lexical: 0.41.0
'@lexical/markdown@0.38.2':
'@lexical/plain-text@0.41.0':
dependencies:
'@lexical/code': 0.38.2
'@lexical/link': 0.38.2
'@lexical/list': 0.38.2
'@lexical/rich-text': 0.38.2
'@lexical/text': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/clipboard': 0.41.0
'@lexical/dragon': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/offset@0.38.2':
dependencies:
lexical: 0.38.2
'@lexical/overflow@0.38.2':
dependencies:
lexical: 0.38.2
'@lexical/plain-text@0.38.2':
dependencies:
'@lexical/clipboard': 0.38.2
'@lexical/dragon': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/react@0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
'@lexical/react@0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
dependencies:
'@floating-ui/react': 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@lexical/devtools-core': 0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@lexical/dragon': 0.38.2
'@lexical/extension': 0.38.2
'@lexical/hashtag': 0.38.2
'@lexical/history': 0.38.2
'@lexical/link': 0.38.2
'@lexical/list': 0.38.2
'@lexical/mark': 0.38.2
'@lexical/markdown': 0.38.2
'@lexical/overflow': 0.38.2
'@lexical/plain-text': 0.38.2
'@lexical/rich-text': 0.38.2
'@lexical/table': 0.38.2
'@lexical/text': 0.38.2
'@lexical/utils': 0.38.2
'@lexical/yjs': 0.38.2(yjs@13.6.29)
lexical: 0.38.2
'@lexical/devtools-core': 0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@lexical/dragon': 0.41.0
'@lexical/extension': 0.41.0
'@lexical/hashtag': 0.41.0
'@lexical/history': 0.41.0
'@lexical/link': 0.41.0
'@lexical/list': 0.41.0
'@lexical/mark': 0.41.0
'@lexical/markdown': 0.41.0
'@lexical/overflow': 0.41.0
'@lexical/plain-text': 0.41.0
'@lexical/rich-text': 0.41.0
'@lexical/table': 0.41.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
'@lexical/yjs': 0.41.0(yjs@13.6.29)
lexical: 0.41.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-error-boundary: 6.1.0(react@19.2.4)
transitivePeerDependencies:
- yjs
'@lexical/rich-text@0.38.2':
'@lexical/rich-text@0.41.0':
dependencies:
'@lexical/clipboard': 0.38.2
'@lexical/dragon': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/clipboard': 0.41.0
'@lexical/dragon': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/selection@0.38.2':
'@lexical/selection@0.41.0':
dependencies:
lexical: 0.38.2
lexical: 0.41.0
'@lexical/selection@0.39.0':
'@lexical/table@0.41.0':
dependencies:
lexical: 0.39.0
'@lexical/clipboard': 0.41.0
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/table@0.38.2':
'@lexical/text@0.41.0':
dependencies:
'@lexical/clipboard': 0.38.2
'@lexical/extension': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
lexical: 0.41.0
'@lexical/table@0.39.0':
'@lexical/utils@0.41.0':
dependencies:
'@lexical/clipboard': 0.39.0
'@lexical/extension': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
'@lexical/selection': 0.41.0
lexical: 0.41.0
'@lexical/text@0.38.2':
'@lexical/yjs@0.41.0(yjs@13.6.29)':
dependencies:
lexical: 0.38.2
'@lexical/utils@0.38.2':
dependencies:
'@lexical/list': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/table': 0.38.2
lexical: 0.38.2
'@lexical/utils@0.39.0':
dependencies:
'@lexical/list': 0.39.0
'@lexical/selection': 0.39.0
'@lexical/table': 0.39.0
lexical: 0.39.0
'@lexical/yjs@0.38.2(yjs@13.6.29)':
dependencies:
'@lexical/offset': 0.38.2
'@lexical/selection': 0.38.2
lexical: 0.38.2
'@lexical/offset': 0.41.0
'@lexical/selection': 0.41.0
lexical: 0.41.0
yjs: 13.6.29
'@mdx-js/loader@3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
@ -11372,7 +11299,7 @@ snapshots:
agent-base@7.1.4: {}
agentation@2.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
agentation@2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -13529,9 +13456,12 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lexical@0.38.2: {}
lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0):
dependencies:
'@lexical/utils': 0.41.0
lexical: 0.41.0
lexical@0.39.0: {}
lexical@0.41.0: {}
lib0@0.2.117:
dependencies:
@ -15884,10 +15814,11 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vinext@https://pkg.pr.new/hyoban/vinext@556a6d6(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
vinext@https://pkg.pr.new/vinext@1a2fd61(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
dependencies:
'@unpic/react': 1.0.2(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/og': 0.8.6
'@vitejs/plugin-react': 5.1.4(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21
react: 19.2.4

View File

@ -46,7 +46,6 @@ export default defineConfig(({ mode }) => {
injectTarget: browserInitializerInjectTarget,
projectRoot,
}),
react(),
vinext(),
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
// reactGrabOpenFilePlugin({
@ -65,13 +64,6 @@ export default defineConfig(({ mode }) => {
? {
optimizeDeps: {
exclude: ['nuqs'],
// Make Prism in lexical works
// https://github.com/vitejs/rolldown-vite/issues/396
rolldownOptions: {
output: {
strictExecutionOrder: true,
},
},
},
server: {
port: 3000,
@ -80,15 +72,6 @@ export default defineConfig(({ mode }) => {
// SyntaxError: Named export not found. The requested module is a CommonJS module, which may not support all module.exports as named exports
noExternal: ['emoji-mart'],
},
// Make Prism in lexical works
// https://github.com/vitejs/rolldown-vite/issues/396
build: {
rolldownOptions: {
output: {
strictExecutionOrder: true,
},
},
},
}
: {}),

View File

@ -80,6 +80,16 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
Element.prototype.scrollIntoView = function () { /* noop */ }
// Mock DOMRect.fromRect for tests (not available in jsdom)
if (typeof DOMRect !== 'undefined' && typeof (DOMRect as typeof DOMRect & { fromRect?: unknown }).fromRect !== 'function') {
(DOMRect as typeof DOMRect & { fromRect: (rect?: DOMRectInit) => DOMRect }).fromRect = (rect = {}) => new DOMRect(
rect.x ?? 0,
rect.y ?? 0,
rect.width ?? 0,
rect.height ?? 0,
)
}
afterEach(async () => {
// Wrap cleanup in act() to flush pending React scheduler work
// This prevents "window is not defined" errors from React 19's scheduler