mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 08:00:13 -05:00
Compare commits
34 Commits
refactor/t
...
test/share
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98d0ccc120 | ||
|
|
f29e96ab4a | ||
|
|
1f3014bbc4 | ||
|
|
5b4c7b2a40 | ||
|
|
378a1d7d08 | ||
|
|
ce0192620d | ||
|
|
e9feeedc01 | ||
|
|
e32490f54e | ||
|
|
e9db50f781 | ||
|
|
0310f631ee | ||
|
|
abc5a61e98 | ||
|
|
5f1698add6 | ||
|
|
36e50f277f | ||
|
|
704ee40caa | ||
|
|
3119c99979 | ||
|
|
16b8733886 | ||
|
|
83f64104fd | ||
|
|
5077879886 | ||
|
|
697b57631a | ||
|
|
6015f23e79 | ||
|
|
f355c8d595 | ||
|
|
0142001fc2 | ||
|
|
4058e9ae23 | ||
|
|
95310561ec | ||
|
|
de33561a52 | ||
|
|
6d9665578b | ||
|
|
18f14c04dc | ||
|
|
14251b249d | ||
|
|
1819bd72ef | ||
|
|
7dabc03a08 | ||
|
|
1a050c9f86 | ||
|
|
7fb6e0cdfe | ||
|
|
e0fcf33979 | ||
|
|
898e09264b |
2
api/.vscode/launch.json.example
vendored
2
api/.vscode/launch.json.example
vendored
@@ -54,7 +54,7 @@
|
||||
"--loglevel",
|
||||
"DEBUG",
|
||||
"-Q",
|
||||
"dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
"dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,workflow_based_app_execution,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -259,11 +259,20 @@ class CeleryConfig(DatabaseConfig):
|
||||
description="Password of the Redis Sentinel master.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT: PositiveFloat | None = Field(
|
||||
description="Timeout for Redis Sentinel socket operations in seconds.",
|
||||
default=0.1,
|
||||
)
|
||||
|
||||
CELERY_TASK_ANNOTATIONS: dict[str, Any] | None = Field(
|
||||
description=(
|
||||
"Annotations for Celery tasks as a JSON mapping of task name -> options "
|
||||
"(for example, rate limits or other task-specific settings)."
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
def CELERY_RESULT_BACKEND(self) -> str | None:
|
||||
if self.CELERY_BACKEND in ("database", "rabbitmq"):
|
||||
|
||||
@@ -21,6 +21,7 @@ language_timezone_mapping = {
|
||||
"th-TH": "Asia/Bangkok",
|
||||
"id-ID": "Asia/Jakarta",
|
||||
"ar-TN": "Africa/Tunis",
|
||||
"nl-NL": "Europe/Amsterdam",
|
||||
}
|
||||
|
||||
languages = list(language_timezone_mapping.keys())
|
||||
|
||||
@@ -599,7 +599,12 @@ def _get_conversation(app_model, conversation_id):
|
||||
db.session.execute(
|
||||
sa.update(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.read_at.is_(None))
|
||||
.values(read_at=naive_utc_now(), read_account_id=current_user.id)
|
||||
# Keep updated_at unchanged when only marking a conversation as read.
|
||||
.values(
|
||||
read_at=naive_utc_now(),
|
||||
read_account_id=current_user.id,
|
||||
updated_at=Conversation.updated_at,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
db.session.refresh(conversation)
|
||||
|
||||
@@ -42,7 +42,15 @@ class SetupResponse(BaseModel):
|
||||
tags=["console"],
|
||||
)
|
||||
def get_setup_status_api() -> SetupStatusResponse:
|
||||
"""Get system setup status."""
|
||||
"""Get system setup status.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design.
|
||||
|
||||
During first-time bootstrap there is no admin account yet, so frontend initialization must be
|
||||
able to query setup progress before any login flow exists.
|
||||
|
||||
Only bootstrap-safe status information should be returned by this endpoint.
|
||||
"""
|
||||
if dify_config.EDITION == "SELF_HOSTED":
|
||||
setup_status = get_setup_status()
|
||||
if setup_status and not isinstance(setup_status, bool):
|
||||
@@ -61,7 +69,12 @@ def get_setup_status_api() -> SetupStatusResponse:
|
||||
)
|
||||
@only_edition_self_hosted
|
||||
def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
"""Initialize system setup with admin account."""
|
||||
"""Initialize system setup with admin account.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
|
||||
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
and init-password validation rather than user session authentication.
|
||||
"""
|
||||
if get_setup_status():
|
||||
raise AlreadySetupError()
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def stream_topic_events(
|
||||
on_subscribe()
|
||||
while True:
|
||||
try:
|
||||
msg = sub.receive(timeout=0.1)
|
||||
msg = sub.receive(timeout=1)
|
||||
except SubscriptionClosedError:
|
||||
return
|
||||
if msg is None:
|
||||
|
||||
@@ -45,6 +45,8 @@ from core.app.entities.task_entities import (
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
|
||||
from core.file import helpers as file_helpers
|
||||
from core.file.enums import FileTransferMethod
|
||||
from core.model_manager import ModelInstance
|
||||
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from core.model_runtime.entities.message_entities import (
|
||||
@@ -56,10 +58,11 @@ from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.prompt.utils.prompt_message_util import PromptMessageUtil
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.tools.signature import sign_tool_file
|
||||
from events.message_event import message_was_created
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import AppMode, Conversation, Message, MessageAgentThought
|
||||
from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -463,6 +466,85 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
|
||||
metadata=metadata_dict,
|
||||
)
|
||||
|
||||
def _record_files(self):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
|
||||
if not message_files:
|
||||
return None
|
||||
|
||||
files_list = []
|
||||
upload_file_ids = [
|
||||
mf.upload_file_id
|
||||
for mf in message_files
|
||||
if mf.transfer_method == FileTransferMethod.LOCAL_FILE and mf.upload_file_id
|
||||
]
|
||||
upload_files_map = {}
|
||||
if upload_file_ids:
|
||||
upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(upload_file_ids))).all()
|
||||
upload_files_map = {uf.id: uf for uf in upload_files}
|
||||
|
||||
for message_file in message_files:
|
||||
upload_file = None
|
||||
if message_file.transfer_method == FileTransferMethod.LOCAL_FILE and message_file.upload_file_id:
|
||||
upload_file = upload_files_map.get(message_file.upload_file_id)
|
||||
|
||||
url = None
|
||||
filename = "file"
|
||||
mime_type = "application/octet-stream"
|
||||
size = 0
|
||||
extension = ""
|
||||
|
||||
if message_file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = message_file.url
|
||||
if message_file.url:
|
||||
filename = message_file.url.split("/")[-1].split("?")[0] # Remove query params
|
||||
elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE:
|
||||
if upload_file:
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id))
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.mime_type or "application/octet-stream"
|
||||
size = upload_file.size or 0
|
||||
extension = f".{upload_file.extension}" if upload_file.extension else ""
|
||||
elif message_file.upload_file_id:
|
||||
# Fallback: generate URL even if upload_file not found
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id))
|
||||
elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url:
|
||||
# For tool files, use URL directly if it's HTTP, otherwise sign it
|
||||
if message_file.url.startswith("http"):
|
||||
url = message_file.url
|
||||
filename = message_file.url.split("/")[-1].split("?")[0]
|
||||
else:
|
||||
# Extract tool file id and extension from URL
|
||||
url_parts = message_file.url.split("/")
|
||||
if url_parts:
|
||||
file_part = url_parts[-1].split("?")[0] # Remove query params first
|
||||
# Use rsplit to correctly handle filenames with multiple dots
|
||||
if "." in file_part:
|
||||
tool_file_id, ext = file_part.rsplit(".", 1)
|
||||
extension = f".{ext}"
|
||||
else:
|
||||
tool_file_id = file_part
|
||||
extension = ".bin"
|
||||
url = sign_tool_file(tool_file_id=tool_file_id, extension=extension)
|
||||
filename = file_part
|
||||
|
||||
transfer_method_value = message_file.transfer_method
|
||||
remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else ""
|
||||
file_dict = {
|
||||
"related_id": message_file.id,
|
||||
"extension": extension,
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"transfer_method": transfer_method_value,
|
||||
"type": message_file.type,
|
||||
"url": url or "",
|
||||
"upload_file_id": message_file.upload_file_id or message_file.id,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
files_list.append(file_dict)
|
||||
return files_list or None
|
||||
|
||||
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
|
||||
"""
|
||||
Agent message to stream response.
|
||||
|
||||
@@ -64,7 +64,13 @@ class MessageCycleManager:
|
||||
|
||||
# Use SQLAlchemy 2.x style session.scalar(select(...))
|
||||
with session_factory.create_session() as session:
|
||||
message_file = session.scalar(select(MessageFile).where(MessageFile.message_id == message_id))
|
||||
message_file = session.scalar(
|
||||
select(MessageFile)
|
||||
.where(
|
||||
MessageFile.message_id == message_id,
|
||||
)
|
||||
.where(MessageFile.belongs_to == "assistant")
|
||||
)
|
||||
|
||||
if message_file:
|
||||
self._message_has_file.add(message_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Generator
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from models.model import File
|
||||
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -171,7 +171,7 @@ class Tool(ABC):
|
||||
def create_file_message(self, file: File) -> ToolInvokeMessage:
|
||||
return ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.FILE,
|
||||
message=ToolInvokeMessage.FileMessage(),
|
||||
message=ToolInvokeMessage.FileMessage(file_marker="file_marker"),
|
||||
meta={"file": file},
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +80,14 @@ def init_app(app: DifyApp) -> Celery:
|
||||
worker_hijack_root_logger=False,
|
||||
timezone=pytz.timezone(dify_config.LOG_TZ or "UTC"),
|
||||
task_ignore_result=True,
|
||||
task_annotations=dify_config.CELERY_TASK_ANNOTATIONS,
|
||||
)
|
||||
|
||||
if dify_config.CELERY_BACKEND == "redis":
|
||||
celery_app.conf.update(
|
||||
result_backend_transport_options=broker_transport_options,
|
||||
)
|
||||
|
||||
# Apply SSL configuration if enabled
|
||||
ssl_options = _get_celery_ssl_options()
|
||||
if ssl_options:
|
||||
|
||||
@@ -119,7 +119,7 @@ class RedisClientWrapper:
|
||||
|
||||
|
||||
redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
pubsub_redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
_pubsub_redis_client: redis.Redis | RedisCluster | None = None
|
||||
|
||||
|
||||
def _get_ssl_configuration() -> tuple[type[Union[Connection, SSLConnection]], dict[str, Any]]:
|
||||
@@ -232,7 +232,7 @@ def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis
|
||||
return client
|
||||
|
||||
|
||||
def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> Union[redis.Redis, RedisCluster]:
|
||||
def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis | RedisCluster:
|
||||
if use_clusters:
|
||||
return RedisCluster.from_url(pubsub_url)
|
||||
return redis.Redis.from_url(pubsub_url)
|
||||
@@ -256,23 +256,19 @@ def init_app(app: DifyApp):
|
||||
redis_client.initialize(client)
|
||||
app.extensions["redis"] = redis_client
|
||||
|
||||
pubsub_client = client
|
||||
global _pubsub_redis_client
|
||||
_pubsub_redis_client = client
|
||||
if dify_config.normalized_pubsub_redis_url:
|
||||
pubsub_client = _create_pubsub_client(
|
||||
_pubsub_redis_client = _create_pubsub_client(
|
||||
dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
|
||||
)
|
||||
pubsub_redis_client.initialize(pubsub_client)
|
||||
|
||||
|
||||
def get_pubsub_redis_client() -> RedisClientWrapper:
|
||||
return pubsub_redis_client
|
||||
|
||||
|
||||
def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
|
||||
redis_conn = get_pubsub_redis_client()
|
||||
assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here."
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
|
||||
return ShardedRedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
|
||||
return RedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client)
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar, cast
|
||||
from typing import ParamSpec, TypeVar, cast
|
||||
|
||||
from opentelemetry.trace import get_tracer
|
||||
|
||||
@@ -8,7 +8,8 @@ from configs import dify_config
|
||||
from extensions.otel.decorators.handler import SpanHandler
|
||||
from extensions.otel.runtime import is_instrument_flag_enabled
|
||||
|
||||
T = TypeVar("T", bound=Callable[..., Any])
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
_HANDLER_INSTANCES: dict[type[SpanHandler], SpanHandler] = {SpanHandler: SpanHandler()}
|
||||
|
||||
@@ -20,7 +21,7 @@ def _get_handler_instance(handler_class: type[SpanHandler]) -> SpanHandler:
|
||||
return _HANDLER_INSTANCES[handler_class]
|
||||
|
||||
|
||||
def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T], T]:
|
||||
def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""
|
||||
Decorator that traces a function with an OpenTelemetry span.
|
||||
|
||||
@@ -30,9 +31,9 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
|
||||
:param handler_class: Optional handler class to use for this span. If None, uses the default SpanHandler.
|
||||
"""
|
||||
|
||||
def decorator(func: T) -> T:
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -46,6 +47,6 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
return cast(T, wrapper)
|
||||
return cast(Callable[P, R], wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import inspect
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from opentelemetry.trace import SpanKind, Status, StatusCode
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
class SpanHandler:
|
||||
"""
|
||||
@@ -31,9 +33,9 @@ class SpanHandler:
|
||||
|
||||
def _extract_arguments(
|
||||
self,
|
||||
wrapped: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any],
|
||||
wrapped: Callable[..., R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Extract function arguments using inspect.signature.
|
||||
@@ -62,10 +64,10 @@ class SpanHandler:
|
||||
def wrapper(
|
||||
self,
|
||||
tracer: Any,
|
||||
wrapped: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any],
|
||||
) -> Any:
|
||||
wrapped: Callable[..., R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> R:
|
||||
"""
|
||||
Fully control the wrapper behavior.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from opentelemetry.trace import SpanKind, Status, StatusCode
|
||||
from opentelemetry.util.types import AttributeValue
|
||||
@@ -12,16 +12,19 @@ from models.model import Account
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
class AppGenerateHandler(SpanHandler):
|
||||
"""Span handler for ``AppGenerateService.generate``."""
|
||||
|
||||
def wrapper(
|
||||
self,
|
||||
tracer: Any,
|
||||
wrapped: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any],
|
||||
) -> Any:
|
||||
wrapped: Callable[..., R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> R:
|
||||
try:
|
||||
arguments = self._extract_arguments(wrapped, args, kwargs)
|
||||
if not arguments:
|
||||
|
||||
@@ -152,7 +152,7 @@ class RedisSubscriptionBase(Subscription):
|
||||
"""Iterator for consuming messages from the subscription."""
|
||||
while not self._closed.is_set():
|
||||
try:
|
||||
item = self._queue.get(timeout=0.1)
|
||||
item = self._queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from libs.broadcast_channel.channel import Producer, Subscriber, Subscription
|
||||
from redis import Redis
|
||||
from redis import Redis, RedisCluster
|
||||
|
||||
from ._subscription import RedisSubscriptionBase
|
||||
|
||||
@@ -18,7 +18,7 @@ class BroadcastChannel:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis,
|
||||
redis_client: Redis | RedisCluster,
|
||||
):
|
||||
self._client = redis_client
|
||||
|
||||
@@ -27,7 +27,7 @@ class BroadcastChannel:
|
||||
|
||||
|
||||
class Topic:
|
||||
def __init__(self, redis_client: Redis, topic: str):
|
||||
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
|
||||
|
||||
@@ -70,8 +70,9 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
# Since we have already filtered at the caller's site, we can safely set
|
||||
# `ignore_subscribe_messages=False`.
|
||||
if isinstance(self._client, RedisCluster):
|
||||
# NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message`
|
||||
# would use busy-looping to wait for incoming message, consuming excessive CPU quota.
|
||||
# NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message` without
|
||||
# specifying the `target_node` argument would use busy-looping to wait
|
||||
# for incoming message, consuming excessive CPU quota.
|
||||
#
|
||||
# Here we specify the `target_node` to mitigate this problem.
|
||||
node = self._client.get_node_from_key(self._topic)
|
||||
@@ -80,8 +81,10 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
timeout=1,
|
||||
target_node=node,
|
||||
)
|
||||
else:
|
||||
elif isinstance(self._client, Redis):
|
||||
return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=1) # type: ignore[attr-defined]
|
||||
else:
|
||||
raise AssertionError("client should be either Redis or RedisCluster.")
|
||||
|
||||
def _get_message_type(self) -> str:
|
||||
return "smessage"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add unique constraint to tenant_default_models
|
||||
|
||||
Revision ID: fix_tenant_default_model_unique
|
||||
Revises: 9d77545f524e
|
||||
Create Date: 2026-01-19 15:07:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _is_pg(conn):
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f55813ffe2c8'
|
||||
down_revision = 'c3df22613c99'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# First, remove duplicate records keeping only the most recent one per (tenant_id, model_type)
|
||||
# This is necessary before adding the unique constraint
|
||||
conn = op.get_bind()
|
||||
|
||||
# Delete duplicates: keep the record with the latest updated_at for each (tenant_id, model_type)
|
||||
# If updated_at is the same, keep the one with the largest id as tiebreaker
|
||||
if _is_pg(conn):
|
||||
# PostgreSQL: Use DISTINCT ON for efficient deduplication
|
||||
conn.execute(sa.text("""
|
||||
DELETE FROM tenant_default_models
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (tenant_id, model_type) id
|
||||
FROM tenant_default_models
|
||||
ORDER BY tenant_id, model_type, updated_at DESC, id DESC
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
# MySQL: Use self-join to find and delete duplicates
|
||||
# Keep the record with latest updated_at (or largest id if updated_at is equal)
|
||||
conn.execute(sa.text("""
|
||||
DELETE t1 FROM tenant_default_models t1
|
||||
INNER JOIN tenant_default_models t2
|
||||
ON t1.tenant_id = t2.tenant_id
|
||||
AND t1.model_type = t2.model_type
|
||||
AND (t1.updated_at < t2.updated_at
|
||||
OR (t1.updated_at = t2.updated_at AND t1.id < t2.id))
|
||||
"""))
|
||||
|
||||
# Now add the unique constraint
|
||||
with op.batch_alter_table('tenant_default_models', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint('unique_tenant_default_model_type', ['tenant_id', 'model_type'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('tenant_default_models', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('unique_tenant_default_model_type', type_='unique')
|
||||
@@ -227,7 +227,7 @@ class App(Base):
|
||||
with Session(db.engine) as session:
|
||||
if api_provider_ids:
|
||||
existing_api_providers = [
|
||||
api_provider.id
|
||||
str(api_provider.id)
|
||||
for api_provider in session.execute(
|
||||
text("SELECT id FROM tool_api_providers WHERE id IN :provider_ids"),
|
||||
{"provider_ids": tuple(api_provider_ids)},
|
||||
|
||||
@@ -181,6 +181,7 @@ class TenantDefaultModel(TypeBase):
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="tenant_default_model_pkey"),
|
||||
sa.Index("tenant_default_model_tenant_id_provider_type_idx", "tenant_id", "provider_name", "model_type"),
|
||||
sa.UniqueConstraint("tenant_id", "model_type", name="unique_tenant_default_model_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"gevent~=25.9.1",
|
||||
"gmpy2~=2.2.1",
|
||||
"google-api-core==2.18.0",
|
||||
"google-api-python-client==2.90.0",
|
||||
"google-api-python-client==2.189.0",
|
||||
"google-auth==2.29.0",
|
||||
"google-auth-httplib2==0.2.0",
|
||||
"google-cloud-aiplatform==1.49.0",
|
||||
|
||||
@@ -1225,7 +1225,12 @@ class TenantService:
|
||||
|
||||
@staticmethod
|
||||
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
|
||||
"""Remove member from tenant"""
|
||||
"""Remove member from tenant.
|
||||
|
||||
If the removed member has ``AccountStatus.PENDING`` (invited but never
|
||||
activated) and no remaining workspace memberships, the orphaned account
|
||||
record is deleted as well.
|
||||
"""
|
||||
if operator.id == account.id:
|
||||
raise CannotOperateSelfError("Cannot operate self.")
|
||||
|
||||
@@ -1235,9 +1240,31 @@ class TenantService:
|
||||
if not ta:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
# Capture identifiers before any deletions; attribute access on the ORM
|
||||
# object may fail after commit() expires the instance.
|
||||
account_id = account.id
|
||||
account_email = account.email
|
||||
|
||||
db.session.delete(ta)
|
||||
|
||||
# Clean up orphaned pending accounts (invited but never activated)
|
||||
should_delete_account = False
|
||||
if account.status == AccountStatus.PENDING:
|
||||
# autoflush flushes ta deletion before this query, so 0 means no remaining joins
|
||||
remaining_joins = db.session.query(TenantAccountJoin).filter_by(account_id=account_id).count()
|
||||
if remaining_joins == 0:
|
||||
db.session.delete(account)
|
||||
should_delete_account = True
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if should_delete_account:
|
||||
logger.info(
|
||||
"Deleted orphaned pending account: account_id=%s, email=%s",
|
||||
account_id,
|
||||
account_email,
|
||||
)
|
||||
|
||||
if dify_config.BILLING_ENABLED:
|
||||
BillingService.clean_billing_info_cache(tenant.id)
|
||||
|
||||
@@ -1245,13 +1272,13 @@ class TenantService:
|
||||
from services.enterprise.account_deletion_sync import sync_workspace_member_removal
|
||||
|
||||
sync_success = sync_workspace_member_removal(
|
||||
workspace_id=tenant.id, member_id=account.id, source="workspace_member_removed"
|
||||
workspace_id=tenant.id, member_id=account_id, source="workspace_member_removed"
|
||||
)
|
||||
if not sync_success:
|
||||
logger.warning(
|
||||
"Enterprise workspace member removal sync failed: workspace_id=%s, member_id=%s",
|
||||
tenant.id,
|
||||
account.id,
|
||||
account_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -22,7 +22,7 @@ from libs.exception import BaseHTTPException
|
||||
from models.human_input import RecipientType
|
||||
from models.model import App, AppMode
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE, resume_app_execution
|
||||
from tasks.app_generate.workflow_execute_task import resume_app_execution
|
||||
|
||||
|
||||
class Form:
|
||||
@@ -230,7 +230,6 @@ class HumanInputService:
|
||||
try:
|
||||
resume_app_execution.apply_async(
|
||||
kwargs={"payload": payload},
|
||||
queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE,
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Failed to enqueue resume task for workflow run %s", workflow_run_id)
|
||||
|
||||
@@ -129,15 +129,15 @@ def build_workflow_event_stream(
|
||||
return
|
||||
|
||||
try:
|
||||
event = buffer_state.queue.get(timeout=0.1)
|
||||
event = buffer_state.queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
current_time = time.time()
|
||||
if current_time - last_msg_time > idle_timeout:
|
||||
logger.debug(
|
||||
"No workflow events received for %s seconds, keeping stream open",
|
||||
"Idle timeout of %s seconds reached, closing workflow event stream.",
|
||||
idle_timeout,
|
||||
)
|
||||
last_msg_time = current_time
|
||||
return
|
||||
if current_time - last_ping_time >= ping_interval:
|
||||
yield StreamEvent.PING.value
|
||||
last_ping_time = current_time
|
||||
@@ -405,7 +405,7 @@ def _start_buffering(subscription) -> BufferState:
|
||||
dropped_count = 0
|
||||
try:
|
||||
while not buffer_state.stop_event.is_set():
|
||||
msg = subscription.receive(timeout=0.1)
|
||||
msg = subscription.receive(timeout=1)
|
||||
if msg is None:
|
||||
continue
|
||||
event = _parse_event_message(msg)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask_login import current_user
|
||||
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import TenantService
|
||||
@@ -53,7 +54,12 @@ class WorkspaceService:
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
paid_pool = CreditPoolService.get_pool(tenant_id=tenant.id, pool_type="paid")
|
||||
if paid_pool:
|
||||
# if the tenant is not on the sandbox plan and the paid pool is not full, use the paid pool
|
||||
if (
|
||||
feature.billing.subscription.plan != CloudPlan.SANDBOX
|
||||
and paid_pool is not None
|
||||
and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used)
|
||||
):
|
||||
tenant_info["trial_credits"] = paid_pool.quota_limit
|
||||
tenant_info["trial_credits_used"] = paid_pool.quota_used
|
||||
else:
|
||||
|
||||
@@ -51,7 +51,7 @@ def _patch_redis_clients_on_loaded_modules():
|
||||
continue
|
||||
if hasattr(module, "redis_client"):
|
||||
module.redis_client = redis_mock
|
||||
if hasattr(module, "pubsub_redis_client"):
|
||||
if hasattr(module, "_pubsub_redis_client"):
|
||||
module.pubsub_redis_client = redis_mock
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ def _patch_redis_clients():
|
||||
|
||||
with (
|
||||
patch.object(ext_redis, "redis_client", redis_mock),
|
||||
patch.object(ext_redis, "pubsub_redis_client", redis_mock),
|
||||
patch.object(ext_redis, "_pubsub_redis_client", redis_mock),
|
||||
):
|
||||
_patch_redis_clients_on_loaded_modules()
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from controllers.console.app.conversation import _get_conversation
|
||||
|
||||
|
||||
def test_get_conversation_mark_read_keeps_updated_at_unchanged():
|
||||
app_model = SimpleNamespace(id="app-id")
|
||||
account = SimpleNamespace(id="account-id")
|
||||
conversation = MagicMock()
|
||||
conversation.id = "conversation-id"
|
||||
|
||||
with (
|
||||
patch("controllers.console.app.conversation.current_account_with_tenant", return_value=(account, None)),
|
||||
patch("controllers.console.app.conversation.naive_utc_now", return_value=datetime(2026, 2, 9, 0, 0, 0)),
|
||||
patch("controllers.console.app.conversation.db.session") as mock_session,
|
||||
):
|
||||
mock_session.query.return_value.where.return_value.first.return_value = conversation
|
||||
|
||||
_get_conversation(app_model, "conversation-id")
|
||||
|
||||
statement = mock_session.execute.call_args[0][0]
|
||||
compiled = statement.compile()
|
||||
sql_text = str(compiled).lower()
|
||||
compact_sql_text = sql_text.replace(" ", "")
|
||||
params = compiled.params
|
||||
|
||||
assert "updated_at=current_timestamp" not in compact_sql_text
|
||||
assert "updated_at=conversations.updated_at" in compact_sql_text
|
||||
assert "read_at=:read_at" in compact_sql_text
|
||||
assert "read_account_id=:read_account_id" in compact_sql_text
|
||||
assert params["read_at"] == datetime(2026, 2, 9, 0, 0, 0)
|
||||
assert params["read_account_id"] == "account-id"
|
||||
@@ -25,15 +25,19 @@ class TestMessageCycleManagerOptimization:
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has files."""
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files.
|
||||
|
||||
This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event,
|
||||
allowing the frontend to properly display generated image files with url field.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute
|
||||
@@ -44,6 +48,31 @@ class TestMessageCycleManagerOptimization:
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message only has user-uploaded files.
|
||||
|
||||
This is a regression test for the issue where user-uploaded images (belongs_to='user')
|
||||
caused the LLM text response to be incorrectly tagged with MESSAGE_FILE event,
|
||||
resulting in broken images in the chat UI. The query filters for belongs_to='assistant',
|
||||
so when only user files exist, the database query returns None, resulting in MESSAGE event type.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# When querying for assistant files with only user files present, return None
|
||||
# (simulates database query with belongs_to='assistant' filter returning no results)
|
||||
mock_session.scalar.return_value = None
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
@@ -69,7 +98,7 @@ class TestMessageCycleManagerOptimization:
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
|
||||
211
api/tests/unit_tests/core/tools/test_base_tool.py
Normal file
211
api/tests/unit_tests/core/tools/test_base_tool.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
|
||||
|
||||
|
||||
class DummyCastType:
|
||||
def cast_value(self, value: Any) -> str:
|
||||
return f"cast:{value}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyParameter:
|
||||
name: str
|
||||
type: DummyCastType
|
||||
form: str = "llm"
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
options: list[Any] | None = None
|
||||
llm_description: str | None = None
|
||||
|
||||
|
||||
class DummyTool(Tool):
|
||||
def __init__(self, entity: ToolEntity, runtime: ToolRuntime):
|
||||
super().__init__(entity=entity, runtime=runtime)
|
||||
self.result: ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None] = (
|
||||
self.create_text_message("default")
|
||||
)
|
||||
self.runtime_parameter_overrides: list[Any] | None = None
|
||||
self.last_invocation: dict[str, Any] | None = None
|
||||
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.BUILT_IN
|
||||
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_parameters: dict[str, Any],
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]:
|
||||
self.last_invocation = {
|
||||
"user_id": user_id,
|
||||
"tool_parameters": tool_parameters,
|
||||
"conversation_id": conversation_id,
|
||||
"app_id": app_id,
|
||||
"message_id": message_id,
|
||||
}
|
||||
return self.result
|
||||
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
if self.runtime_parameter_overrides is not None:
|
||||
return self.runtime_parameter_overrides
|
||||
return super().get_runtime_parameters(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool(runtime: ToolRuntime | None = None) -> DummyTool:
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="dummy", label=I18nObject(en_US="dummy"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = runtime or ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER, runtime_parameters={})
|
||||
return DummyTool(entity=entity, runtime=runtime)
|
||||
|
||||
|
||||
def test_invoke_supports_single_message_and_parameter_casting():
|
||||
runtime = ToolRuntime(
|
||||
tenant_id="tenant-1",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
runtime_parameters={"from_runtime": "runtime-value"},
|
||||
)
|
||||
tool = _build_tool(runtime)
|
||||
tool.entity.parameters = cast(
|
||||
Any,
|
||||
[
|
||||
DummyParameter(name="unused", type=DummyCastType()),
|
||||
DummyParameter(name="age", type=DummyCastType()),
|
||||
],
|
||||
)
|
||||
tool.result = tool.create_text_message("ok")
|
||||
|
||||
messages = list(
|
||||
tool.invoke(
|
||||
user_id="user-1",
|
||||
tool_parameters={"age": "18", "raw": "keep"},
|
||||
conversation_id="conv-1",
|
||||
app_id="app-1",
|
||||
message_id="msg-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].message.text == "ok"
|
||||
assert tool.last_invocation == {
|
||||
"user_id": "user-1",
|
||||
"tool_parameters": {"age": "cast:18", "raw": "keep", "from_runtime": "runtime-value"},
|
||||
"conversation_id": "conv-1",
|
||||
"app_id": "app-1",
|
||||
"message_id": "msg-1",
|
||||
}
|
||||
|
||||
|
||||
def test_invoke_supports_list_and_generator_results():
|
||||
tool = _build_tool()
|
||||
tool.result = [tool.create_text_message("a"), tool.create_text_message("b")]
|
||||
list_messages = list(tool.invoke(user_id="user-1", tool_parameters={}))
|
||||
assert [msg.message.text for msg in list_messages] == ["a", "b"]
|
||||
|
||||
def _message_generator() -> Generator[ToolInvokeMessage, None, None]:
|
||||
yield tool.create_text_message("g1")
|
||||
yield tool.create_text_message("g2")
|
||||
|
||||
tool.result = _message_generator()
|
||||
generated_messages = list(tool.invoke(user_id="user-2", tool_parameters={}))
|
||||
assert [msg.message.text for msg in generated_messages] == ["g1", "g2"]
|
||||
|
||||
|
||||
def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
|
||||
tool = _build_tool()
|
||||
new_runtime = ToolRuntime(tenant_id="tenant-2", invoke_from=InvokeFrom.EXPLORE, runtime_parameters={})
|
||||
|
||||
forked = tool.fork_tool_runtime(new_runtime)
|
||||
|
||||
assert isinstance(forked, DummyTool)
|
||||
assert forked is not tool
|
||||
assert forked.runtime == new_runtime
|
||||
assert forked.entity == tool.entity
|
||||
assert forked.entity is not tool.entity
|
||||
|
||||
|
||||
def test_get_runtime_parameters_and_merge_runtime_parameters():
|
||||
tool = _build_tool()
|
||||
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
|
||||
tool.entity.parameters = cast(Any, [original])
|
||||
|
||||
default_runtime_parameters = tool.get_runtime_parameters()
|
||||
assert default_runtime_parameters == [original]
|
||||
|
||||
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
|
||||
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
|
||||
tool.runtime_parameter_overrides = [override, appended]
|
||||
|
||||
merged = tool.get_merged_runtime_parameters()
|
||||
assert len(merged) == 2
|
||||
assert merged[0].name == "temperature"
|
||||
assert merged[0].form == "llm"
|
||||
assert merged[0].required is False
|
||||
assert merged[0].default == "0.5"
|
||||
assert merged[1].name == "new_param"
|
||||
|
||||
|
||||
def test_message_factory_helpers():
|
||||
tool = _build_tool()
|
||||
|
||||
image_message = tool.create_image_message("https://example.com/image.png")
|
||||
assert image_message.type == ToolInvokeMessage.MessageType.IMAGE
|
||||
assert image_message.message.text == "https://example.com/image.png"
|
||||
|
||||
file_obj = object()
|
||||
file_message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
assert file_message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert file_message.message.file_marker == "file_marker"
|
||||
assert file_message.meta == {"file": file_obj}
|
||||
|
||||
link_message = tool.create_link_message("https://example.com")
|
||||
assert link_message.type == ToolInvokeMessage.MessageType.LINK
|
||||
assert link_message.message.text == "https://example.com"
|
||||
|
||||
text_message = tool.create_text_message("hello")
|
||||
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
|
||||
assert text_message.message.text == "hello"
|
||||
|
||||
blob_message = tool.create_blob_message(b"blob", meta={"source": "unit-test"})
|
||||
assert blob_message.type == ToolInvokeMessage.MessageType.BLOB
|
||||
assert blob_message.message.blob == b"blob"
|
||||
assert blob_message.meta == {"source": "unit-test"}
|
||||
|
||||
json_message = tool.create_json_message({"k": "v"}, suppress_output=True)
|
||||
assert json_message.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert json_message.message.json_object == {"k": "v"}
|
||||
assert json_message.message.suppress_output is True
|
||||
|
||||
variable_message = tool.create_variable_message("answer", 42, stream=False)
|
||||
assert variable_message.type == ToolInvokeMessage.MessageType.VARIABLE
|
||||
assert variable_message.message.variable_name == "answer"
|
||||
assert variable_message.message.variable_value == 42
|
||||
assert variable_message.message.stream is False
|
||||
|
||||
|
||||
def test_base_abstract_invoke_placeholder_returns_none():
|
||||
tool = _build_tool()
|
||||
assert Tool._invoke(tool, user_id="u", tool_parameters={}) is None
|
||||
@@ -255,6 +255,32 @@ def test_create_variable_message():
|
||||
assert message.message.stream is False
|
||||
|
||||
|
||||
def test_create_file_message_should_include_file_marker():
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
file_obj = object()
|
||||
message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
|
||||
assert message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert message.message.file_marker == "file_marker"
|
||||
assert message.meta == {"file": file_obj}
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
|
||||
@@ -198,6 +198,15 @@ class SubscriptionTestCase:
|
||||
description: str = ""
|
||||
|
||||
|
||||
class FakeRedisClient:
|
||||
"""Minimal fake Redis client for unit tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.publish = MagicMock()
|
||||
self.spublish = MagicMock()
|
||||
self.pubsub = MagicMock(return_value=MagicMock())
|
||||
|
||||
|
||||
class TestRedisSubscription:
|
||||
"""Test cases for the _RedisSubscription class."""
|
||||
|
||||
@@ -619,10 +628,13 @@ class TestRedisSubscription:
|
||||
class TestRedisShardedSubscription:
|
||||
"""Test cases for the _RedisShardedSubscription class."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sharded_redis_type(self, monkeypatch):
|
||||
monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self) -> MagicMock:
|
||||
client = MagicMock()
|
||||
return client
|
||||
def mock_redis_client(self) -> FakeRedisClient:
|
||||
return FakeRedisClient()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub(self) -> MagicMock:
|
||||
@@ -636,7 +648,7 @@ class TestRedisShardedSubscription:
|
||||
|
||||
@pytest.fixture
|
||||
def sharded_subscription(
|
||||
self, mock_pubsub: MagicMock, mock_redis_client: MagicMock
|
||||
self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient
|
||||
) -> Generator[_RedisShardedSubscription, None, None]:
|
||||
"""Create a _RedisShardedSubscription instance for testing."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
@@ -657,7 +669,7 @@ class TestRedisShardedSubscription:
|
||||
|
||||
# ==================== Lifecycle Tests ====================
|
||||
|
||||
def test_sharded_subscription_initialization(self, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
def test_sharded_subscription_initialization(self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
"""Test that sharded subscription is properly initialized."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
client=mock_redis_client,
|
||||
@@ -970,7 +982,7 @@ class TestRedisShardedSubscription:
|
||||
],
|
||||
)
|
||||
def test_sharded_subscription_scenarios(
|
||||
self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock, mock_redis_client: MagicMock
|
||||
self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient
|
||||
):
|
||||
"""Test various sharded subscription scenarios using table-driven approach."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
@@ -1058,7 +1070,7 @@ class TestRedisShardedSubscription:
|
||||
# Close should still work
|
||||
sharded_subscription.close() # Should not raise
|
||||
|
||||
def test_channel_name_variations(self, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
def test_channel_name_variations(self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
"""Test various sharded channel name formats."""
|
||||
channel_names = [
|
||||
"simple",
|
||||
@@ -1120,10 +1132,13 @@ class TestRedisSubscriptionCommon:
|
||||
"""Parameterized fixture providing subscription type and class."""
|
||||
return request.param
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sharded_redis_type(self, monkeypatch):
|
||||
monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self) -> MagicMock:
|
||||
client = MagicMock()
|
||||
return client
|
||||
def mock_redis_client(self) -> FakeRedisClient:
|
||||
return FakeRedisClient()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub(self) -> MagicMock:
|
||||
@@ -1140,7 +1155,7 @@ class TestRedisSubscriptionCommon:
|
||||
return pubsub
|
||||
|
||||
@pytest.fixture
|
||||
def subscription(self, subscription_params, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
def subscription(self, subscription_params, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
"""Create a subscription instance based on parameterized type."""
|
||||
subscription_type, subscription_class = subscription_params
|
||||
topic_name = f"test-{subscription_type}-topic"
|
||||
|
||||
@@ -698,6 +698,132 @@ class TestTenantService:
|
||||
|
||||
self._assert_database_operations_called(mock_db_dependencies["db"])
|
||||
|
||||
# ==================== Member Removal Tests ====================
|
||||
|
||||
def test_remove_pending_member_deletes_orphaned_account(self):
|
||||
"""Test that removing a pending member with no other workspaces deletes the account."""
|
||||
# Arrange
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
|
||||
mock_pending_member = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="pending-user-789", email="pending@example.com", status=AccountStatus.PENDING
|
||||
)
|
||||
|
||||
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="pending-user-789", role="normal"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
query_mock_count = MagicMock()
|
||||
query_mock_count.filter_by.return_value.count.return_value = 0
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Act
|
||||
TenantService.remove_member_from_tenant(mock_tenant, mock_pending_member, mock_operator)
|
||||
|
||||
# Assert: enterprise sync still receives the correct member ID
|
||||
mock_sync.assert_called_once_with(
|
||||
workspace_id="tenant-456",
|
||||
member_id="pending-user-789",
|
||||
source="workspace_member_removed",
|
||||
)
|
||||
|
||||
# Assert: both join record and account should be deleted
|
||||
mock_db.session.delete.assert_any_call(mock_ta)
|
||||
mock_db.session.delete.assert_any_call(mock_pending_member)
|
||||
assert mock_db.session.delete.call_count == 2
|
||||
|
||||
def test_remove_pending_member_keeps_account_with_other_workspaces(self):
|
||||
"""Test that removing a pending member who belongs to other workspaces preserves the account."""
|
||||
# Arrange
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
|
||||
mock_pending_member = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="pending-user-789", email="pending@example.com", status=AccountStatus.PENDING
|
||||
)
|
||||
|
||||
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="pending-user-789", role="normal"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
# Remaining join count = 1 (still in another workspace)
|
||||
query_mock_count = MagicMock()
|
||||
query_mock_count.filter_by.return_value.count.return_value = 1
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Act
|
||||
TenantService.remove_member_from_tenant(mock_tenant, mock_pending_member, mock_operator)
|
||||
|
||||
# Assert: only the join record should be deleted, not the account
|
||||
mock_db.session.delete.assert_called_once_with(mock_ta)
|
||||
|
||||
def test_remove_active_member_preserves_account(self):
|
||||
"""Test that removing an active member never deletes the account, even with no other workspaces."""
|
||||
# Arrange
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
|
||||
mock_active_member = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="active-user-789", email="active@example.com", status=AccountStatus.ACTIVE
|
||||
)
|
||||
|
||||
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="active-user-789", role="normal"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Act
|
||||
TenantService.remove_member_from_tenant(mock_tenant, mock_active_member, mock_operator)
|
||||
|
||||
# Assert: only the join record should be deleted
|
||||
mock_db.session.delete.assert_called_once_with(mock_ta)
|
||||
|
||||
# ==================== Tenant Switching Tests ====================
|
||||
|
||||
def test_switch_tenant_success(self):
|
||||
|
||||
@@ -17,7 +17,6 @@ from core.workflow.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus
|
||||
from models.human_input import RecipientType
|
||||
from services.human_input_service import Form, FormExpiredError, HumanInputService, InvalidFormDataError
|
||||
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -88,7 +87,6 @@ def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factor
|
||||
|
||||
resume_task.apply_async.assert_called_once()
|
||||
call_kwargs = resume_task.apply_async.call_args.kwargs
|
||||
assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
@@ -130,7 +128,6 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_f
|
||||
|
||||
resume_task.apply_async.assert_called_once()
|
||||
call_kwargs = resume_task.apply_async.call_args.kwargs
|
||||
assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
|
||||
82
api/uv.lock
generated
82
api/uv.lock
generated
@@ -1237,49 +1237,47 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1594,7 +1592,7 @@ requires-dist = [
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.2.1" },
|
||||
{ name = "google-api-core", specifier = "==2.18.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.90.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.189.0" },
|
||||
{ name = "google-auth", specifier = "==2.29.0" },
|
||||
{ name = "google-auth-httplib2", specifier = "==0.2.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = "==1.49.0" },
|
||||
@@ -2306,7 +2304,7 @@ grpc = [
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.90.0"
|
||||
version = "2.189.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2315,9 +2313,9 @@ dependencies = [
|
||||
{ name = "httplib2" },
|
||||
{ name = "uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/8b/d990f947c261304a5c1599d45717d02c27d46af5f23e1fee5dc19c8fa79d/google-api-python-client-2.90.0.tar.gz", hash = "sha256:cbcb3ba8be37c6806676a49df16ac412077e5e5dc7fa967941eff977b31fba03", size = 10891311, upload-time = "2023-06-20T16:29:25.008Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/f8/0783aeca3410ee053d4dd1fccafd85197847b8f84dd038e036634605d083/google_api_python_client-2.189.0.tar.gz", hash = "sha256:45f2d8559b5c895dde6ad3fb33de025f5cb2c197fa5862f18df7f5295a172741", size = 13979470, upload-time = "2026-02-03T19:24:55.432Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/03/209b5c36a621ae644dc7d4743746cd3b38b18e133f8779ecaf6b95cc01ce/google_api_python_client-2.90.0-py2.py3-none-any.whl", hash = "sha256:4a41ffb7797d4f28e44635fb1e7076240b741c6493e7c3233c0e4421cec7c913", size = 11379891, upload-time = "2023-06-20T16:29:19.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/44/3677ff27998214f2fa7957359da48da378a0ffff1bd0bdaba42e752bc13e/google_api_python_client-2.189.0-py3-none-any.whl", hash = "sha256:a258c09660a49c6159173f8bbece171278e917e104a11f0640b34751b79c8a1a", size = 14547633, upload-time = "2026-02-03T19:24:52.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -106,10 +106,10 @@ if [[ -z "${QUEUES}" ]]; then
|
||||
# Configure queues based on edition
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset and workflow have separate queues
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
|
||||
fi
|
||||
|
||||
echo "No queues specified, using edition-based defaults: ${QUEUES}"
|
||||
|
||||
@@ -62,6 +62,9 @@ LANG=C.UTF-8
|
||||
LC_ALL=C.UTF-8
|
||||
PYTHONIOENCODING=utf-8
|
||||
|
||||
# Set UV cache directory to avoid permission issues with non-existent home directory
|
||||
UV_CACHE_DIR=/tmp/.uv-cache
|
||||
|
||||
# ------------------------------
|
||||
# Server Configuration
|
||||
# ------------------------------
|
||||
@@ -384,6 +387,8 @@ CELERY_USE_SENTINEL=false
|
||||
CELERY_SENTINEL_MASTER_NAME=
|
||||
CELERY_SENTINEL_PASSWORD=
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT=0.1
|
||||
# e.g. {"tasks.add": {"rate_limit": "10/s"}}
|
||||
CELERY_TASK_ANNOTATIONS=null
|
||||
|
||||
# ------------------------------
|
||||
# CORS Configuration
|
||||
|
||||
@@ -16,6 +16,7 @@ x-shared-env: &shared-api-worker-env
|
||||
LANG: ${LANG:-C.UTF-8}
|
||||
LC_ALL: ${LC_ALL:-C.UTF-8}
|
||||
PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8}
|
||||
UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
|
||||
LOG_FILE: ${LOG_FILE:-/app/logs/server.log}
|
||||
@@ -105,6 +106,7 @@ x-shared-env: &shared-api-worker-env
|
||||
CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-}
|
||||
CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-}
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1}
|
||||
CELERY_TASK_ANNOTATIONS: ${CELERY_TASK_ANNOTATIONS:-null}
|
||||
WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*}
|
||||
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
|
||||
8
sdks/nodejs-client/pnpm-lock.yaml
generated
8
sdks/nodejs-client/pnpm-lock.yaml
generated
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
version: 1.13.5
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
@@ -544,8 +544,8 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
axios@1.13.5:
|
||||
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -1677,7 +1677,7 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.13.2:
|
||||
axios@1.13.5:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
|
||||
192
web/__tests__/develop/api-key-management-flow.test.tsx
Normal file
192
web/__tests__/develop/api-key-management-flow.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Integration test: API Key management flow
|
||||
*
|
||||
* Tests the cross-component interaction:
|
||||
* ApiServer → SecretKeyButton → SecretKeyModal
|
||||
*
|
||||
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
|
||||
* with only service-layer mocks. Deep modal interactions (create/delete)
|
||||
* are covered by unit tests in secret-key-modal.spec.tsx.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ApiServer from '@/app/components/develop/ApiServer'
|
||||
|
||||
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mocks ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsLoading = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({
|
||||
data: mockApiKeys(),
|
||||
isLoading: mockIsLoading(),
|
||||
}),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('API Key management flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApiKeys.mockReturnValue({ data: [] })
|
||||
mockIsLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('ApiServer renders URL, status badge, and API Key button', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Click API Key button (rendered by SecretKeyButton)
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should render with real HeadlessUI Dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('modal shows loading state when API keys are being fetched', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
mockIsLoading.mockReturnValue(true)
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Loading indicator should be present
|
||||
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('modal can be closed by clicking X icon', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Open modal
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click X icon to close
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly with different API URLs', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
const { rerender } = render(
|
||||
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
|
||||
|
||||
// Open modal and verify it works with the same appId
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal, update URL and re-verify
|
||||
const xIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
await act(async () => {
|
||||
await user.click(xIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
rerender(
|
||||
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
241
web/__tests__/develop/develop-page-flow.test.tsx
Normal file
241
web/__tests__/develop/develop-page-flow.test.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Integration test: DevelopMain page flow
|
||||
*
|
||||
* Tests the full page lifecycle:
|
||||
* Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered
|
||||
*
|
||||
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DevelopMain from '@/app/components/develop'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
|
||||
// ---------- fake timers ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- store mock ----------
|
||||
|
||||
let storeAppDetail: unknown
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
return selector({ appDetail: storeAppDetail })
|
||||
},
|
||||
}))
|
||||
|
||||
// ---------- Doc dependencies ----------
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
|
||||
}))
|
||||
|
||||
// ---------- SecretKeyModal dependencies ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('DevelopMain page flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeAppDetail = undefined
|
||||
})
|
||||
|
||||
it('should show loading indicator when appDetail is not available', () => {
|
||||
storeAppDetail = undefined
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
// No content should be visible
|
||||
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render full page when appDetail is loaded', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer section should be visible
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
|
||||
// Loading should NOT be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Doc component with correct app mode template', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Chat App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
const { container } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Doc renders an article element with prose classes
|
||||
const article = container.querySelector('article')
|
||||
expect(article).toBeInTheDocument()
|
||||
expect(article?.className).toContain('prose')
|
||||
})
|
||||
|
||||
it('should transition from loading to content when appDetail becomes available', () => {
|
||||
// Start with no data
|
||||
storeAppDetail = undefined
|
||||
const { rerender } = render(<DevelopMain appId="app-1" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Simulate store update
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
}
|
||||
rerender(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Content should now be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open API key modal from the page', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Click API Key button in the header
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render correctly for different app modes', () => {
|
||||
const modes = [
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.WORKFLOW,
|
||||
]
|
||||
|
||||
for (const mode of modes) {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: `${mode} App`,
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode,
|
||||
}
|
||||
|
||||
const { container, unmount } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer should always be present
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
|
||||
// Doc should render an article
|
||||
expect(container.querySelector('article')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have correct page layout structure', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Main container: flex column with full height
|
||||
const mainDiv = screen.getByTestId('develop-main')
|
||||
expect(mainDiv.className).toContain('flex')
|
||||
expect(mainDiv.className).toContain('flex-col')
|
||||
expect(mainDiv.className).toContain('h-full')
|
||||
|
||||
// Header section with border
|
||||
const header = mainDiv.querySelector('.border-b')
|
||||
expect(header).toBeInTheDocument()
|
||||
|
||||
// Content section with overflow scroll
|
||||
const content = mainDiv.querySelector('.overflow-auto')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => {
|
||||
vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => {
|
||||
if (name === 'docs')
|
||||
return mockDirectCommand
|
||||
if (name === 'theme')
|
||||
return mockSubmenuCommand
|
||||
return null
|
||||
return undefined
|
||||
})
|
||||
;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
mockDirectCommand,
|
||||
mockSubmenuCommand,
|
||||
])
|
||||
@@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => {
|
||||
unregister: vi.fn(),
|
||||
}
|
||||
|
||||
;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode)
|
||||
vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode)
|
||||
|
||||
const handler = slashCommandRegistry.findCommand('test')
|
||||
// Default behavior should be submenu when mode is not specified
|
||||
|
||||
121
web/__tests__/share/text-generation-run-batch-flow.test.tsx
Normal file
121
web/__tests__/share/text-generation-run-batch-flow.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Integration test: RunBatch CSV upload → Run flow
|
||||
*
|
||||
* Tests the complete user journey:
|
||||
* Upload CSV → parse → enable run → click run → results finish → run again
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import RunBatch from '@/app/components/share/text-generation/run-batch'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(() => 'pc'),
|
||||
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
|
||||
}))
|
||||
|
||||
// Capture the onParsed callback from CSVReader to simulate CSV uploads
|
||||
let capturedOnParsed: ((data: string[][]) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({
|
||||
default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => {
|
||||
capturedOnParsed = onParsed
|
||||
return <div data-testid="csv-reader">CSV Reader</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({
|
||||
default: ({ vars }: { vars: { name: string }[] }) => (
|
||||
<div data-testid="csv-download">
|
||||
{vars.map(v => v.name).join(', ')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('RunBatch – integration flow', () => {
|
||||
const vars = [{ name: 'prompt' }, { name: 'context' }]
|
||||
|
||||
beforeEach(() => {
|
||||
capturedOnParsed = undefined
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('full lifecycle: upload CSV → run → finish → run again', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<RunBatch vars={vars} onSend={onSend} isAllFinished />,
|
||||
)
|
||||
|
||||
// Phase 1 – verify child components rendered
|
||||
expect(screen.getByTestId('csv-reader')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context')
|
||||
|
||||
// Run button should be disabled before CSV is parsed
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 2 – simulate CSV upload
|
||||
const csvData = [
|
||||
['prompt', 'context'],
|
||||
['Hello', 'World'],
|
||||
['Goodbye', 'Moon'],
|
||||
]
|
||||
await act(async () => {
|
||||
capturedOnParsed?.(csvData)
|
||||
})
|
||||
|
||||
// Run button should now be enabled
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Phase 3 – click run
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
expect(onSend).toHaveBeenCalledWith(csvData)
|
||||
|
||||
// Phase 4 – simulate results still running
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
// Phase 5 – results finish → can run again
|
||||
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
onSend.mockClear()
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remain disabled when CSV not uploaded even if all finished', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show spinner icon when results are still running', async () => {
|
||||
const onSend = vi.fn()
|
||||
const { container } = render(
|
||||
<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />,
|
||||
)
|
||||
|
||||
// Upload CSV first
|
||||
await act(async () => {
|
||||
capturedOnParsed?.([['data']])
|
||||
})
|
||||
|
||||
// Button disabled + spinning icon
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('animate-spin')
|
||||
})
|
||||
})
|
||||
218
web/__tests__/share/text-generation-run-once-flow.test.tsx
Normal file
218
web/__tests__/share/text-generation-run-once-flow.test.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Integration test: RunOnce form lifecycle
|
||||
*
|
||||
* Tests the complete user journey:
|
||||
* Init defaults → edit fields → submit → running state → stop
|
||||
*/
|
||||
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
|
||||
import type { PromptConfig, PromptVariable } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(() => 'pc'),
|
||||
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => (
|
||||
<textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({
|
||||
default: () => <div data-testid="vision-uploader" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />,
|
||||
}))
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({
|
||||
key: 'k',
|
||||
name: 'Name',
|
||||
type: 'string',
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const visionOff: VisionSettings = {
|
||||
enabled: false,
|
||||
number_limits: 0,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
image_file_size_limit: 5,
|
||||
}
|
||||
|
||||
const siteInfo: SiteInfo = { title: 'Test' }
|
||||
|
||||
/**
|
||||
* Stateful wrapper that mirrors what text-generation/index.tsx does:
|
||||
* owns `inputs` state and passes an `inputsRef`.
|
||||
*/
|
||||
function Harness({
|
||||
promptConfig,
|
||||
visionConfig = visionOff,
|
||||
onSendSpy,
|
||||
runControl = null,
|
||||
}: {
|
||||
promptConfig: PromptConfig
|
||||
visionConfig?: VisionSettings
|
||||
onSendSpy: () => void
|
||||
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
|
||||
}) {
|
||||
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
|
||||
const inputsRef = useRef<Record<string, InputValueTypes>>({})
|
||||
|
||||
return (
|
||||
<RunOnce
|
||||
siteInfo={siteInfo}
|
||||
promptConfig={promptConfig}
|
||||
inputs={inputs}
|
||||
inputsRef={inputsRef}
|
||||
onInputsChange={(updated) => {
|
||||
inputsRef.current = updated
|
||||
setInputs(updated)
|
||||
}}
|
||||
onSend={onSendSpy}
|
||||
visionConfig={visionConfig}
|
||||
onVisionFilesChange={vi.fn()}
|
||||
runControl={runControl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- tests -----
|
||||
|
||||
describe('RunOnce – integration flow', () => {
|
||||
it('full lifecycle: init → edit → submit → running → stop', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'name', name: 'Name', type: 'string', default: '' }),
|
||||
variable({ key: 'age', name: 'Age', type: 'number', default: '' }),
|
||||
variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }),
|
||||
],
|
||||
}
|
||||
|
||||
// Phase 1 – render, wait for initialisation
|
||||
const { rerender } = render(
|
||||
<Harness promptConfig={config} onSendSpy={onSend} />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Phase 2 – fill fields
|
||||
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } })
|
||||
|
||||
// Phase 3 – submit
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 4 – simulate "running" state
|
||||
const onStop = vi.fn()
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const stopBtn = screen.getByTestId('stop-button')
|
||||
expect(stopBtn).toBeInTheDocument()
|
||||
fireEvent.click(stopBtn)
|
||||
expect(onStop).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Phase 5 – simulate "stopping" state
|
||||
rerender(
|
||||
<Harness
|
||||
promptConfig={config}
|
||||
onSendSpy={onSend}
|
||||
runControl={{ onStop, isStopping: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('stop-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clear resets all field types and allows re-submit', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }),
|
||||
variable({ key: 'flag', name: 'Flag', type: 'checkbox' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi')
|
||||
})
|
||||
|
||||
// Clear all
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Question')).toHaveValue('')
|
||||
})
|
||||
|
||||
// Re-fill and submit
|
||||
fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } })
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mixed input types: string + select + json_object', async () => {
|
||||
const onSend = vi.fn()
|
||||
|
||||
const config: PromptConfig = {
|
||||
prompt_template: 'tpl',
|
||||
prompt_variables: [
|
||||
variable({ key: 'txt', name: 'Text', type: 'string', default: '' }),
|
||||
variable({
|
||||
key: 'sel',
|
||||
name: 'Dropdown',
|
||||
type: 'select',
|
||||
options: ['A', 'B'],
|
||||
default: 'A',
|
||||
}),
|
||||
variable({
|
||||
key: 'json',
|
||||
name: 'JSON',
|
||||
type: 'json_object' as PromptVariable['type'],
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
render(<Harness promptConfig={config} onSendSpy={onSend} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('JSON')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edit text & json
|
||||
fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } })
|
||||
fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('run-button'))
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* MAX_PARALLEL_LIMIT Configuration Bug Test
|
||||
*
|
||||
* This test reproduces and verifies the fix for issue #23083:
|
||||
* MAX_PARALLEL_LIMIT environment variable does not take effect in iteration panel
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
// Mock environment variables before importing constants
|
||||
const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Test with different environment values
|
||||
function setupEnvironment(value?: string) {
|
||||
if (value)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = value
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Clear module cache to force re-evaluation
|
||||
vi.resetModules()
|
||||
}
|
||||
|
||||
function restoreEnvironment() {
|
||||
if (originalEnv)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = originalEnv
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
vi.resetModules()
|
||||
}
|
||||
|
||||
// Mock i18next with proper implementation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key.includes('MaxParallelismTitle'))
|
||||
return 'Max Parallelism'
|
||||
if (key.includes('MaxParallelismDesc'))
|
||||
return 'Maximum number of parallel executions'
|
||||
if (key.includes('parallelMode'))
|
||||
return 'Parallel Mode'
|
||||
if (key.includes('parallelPanelDesc'))
|
||||
return 'Enable parallel execution'
|
||||
if (key.includes('errorResponseMethod'))
|
||||
return 'Error Response Method'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock i18next module completely to prevent initialization issues
|
||||
vi.mock('i18next', () => ({
|
||||
use: vi.fn().mockReturnThis(),
|
||||
init: vi.fn().mockReturnThis(),
|
||||
t: vi.fn(key => key),
|
||||
isInitialized: true,
|
||||
}))
|
||||
|
||||
// Mock the useConfig hook
|
||||
vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
|
||||
default: () => ({
|
||||
inputs: {
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated',
|
||||
},
|
||||
changeParallel: vi.fn(),
|
||||
changeParallelNums: vi.fn(),
|
||||
changeErrorHandleMode: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock other components
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: function MockVarReferencePicker() {
|
||||
return <div data-testid="var-reference-picker">VarReferencePicker</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: function MockSplit() {
|
||||
return <div data-testid="split">Split</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: function MockField({ title, children }: { title: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<div data-testid="field">
|
||||
<label>{title}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const getParallelControls = () => ({
|
||||
numberInput: screen.getByRole('spinbutton'),
|
||||
slider: screen.getByRole('slider'),
|
||||
})
|
||||
|
||||
describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
|
||||
const mockNodeData = {
|
||||
id: 'test-iteration-node',
|
||||
type: 'iteration' as const,
|
||||
data: {
|
||||
title: 'Test Iteration',
|
||||
desc: 'Test iteration node',
|
||||
iterator_selector: ['test'],
|
||||
output_selector: ['output'],
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated' as const,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
describe('Environment Variable Parsing', () => {
|
||||
it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', async () => {
|
||||
setupEnvironment('25')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(25)
|
||||
})
|
||||
|
||||
it('should fallback to default when environment variable is not set', async () => {
|
||||
setupEnvironment() // No environment variable
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle invalid environment variable values', async () => {
|
||||
setupEnvironment('invalid')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
// Should fall back to default when parsing fails
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle empty environment variable', async () => {
|
||||
setupEnvironment('')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
// Should fall back to default when empty
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
// Edge cases for boundary values
|
||||
it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', async () => {
|
||||
setupEnvironment('0')
|
||||
let { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
|
||||
setupEnvironment('-5')
|
||||
;({ MAX_PARALLEL_LIMIT } = await import('@/config'))
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
})
|
||||
|
||||
it('should handle float numbers by parseInt behavior', async () => {
|
||||
setupEnvironment('12.7')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
// parseInt truncates to integer
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI Component Integration (Main Fix Verification)', () => {
|
||||
it('should render iteration panel with environment-configured max value', async () => {
|
||||
// Set environment variable to a different value
|
||||
setupEnvironment('30')
|
||||
|
||||
// Import Panel after setting environment
|
||||
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
// @ts-expect-error key type mismatch
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT
|
||||
const { numberInput, slider } = getParallelControls()
|
||||
expect(numberInput).toHaveAttribute('max', String(MAX_PARALLEL_LIMIT))
|
||||
expect(slider).toHaveAttribute('aria-valuemax', String(MAX_PARALLEL_LIMIT))
|
||||
|
||||
// Verify the actual values
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(30)
|
||||
expect(numberInput.getAttribute('max')).toBe('30')
|
||||
expect(slider.getAttribute('aria-valuemax')).toBe('30')
|
||||
})
|
||||
|
||||
it('should maintain UI consistency with different environment values', async () => {
|
||||
setupEnvironment('15')
|
||||
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
// @ts-expect-error key type mismatch
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Both input and slider should use the same max value from MAX_PARALLEL_LIMIT
|
||||
const { numberInput, slider } = getParallelControls()
|
||||
|
||||
expect(numberInput.getAttribute('max')).toBe(slider.getAttribute('aria-valuemax'))
|
||||
expect(numberInput.getAttribute('max')).toBe(String(MAX_PARALLEL_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy Constant Verification (For Transition Period)', () => {
|
||||
// Marked as transition/deprecation tests
|
||||
it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', async () => {
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value
|
||||
})
|
||||
|
||||
it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', async () => {
|
||||
setupEnvironment('50')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
|
||||
// MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(50)
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10)
|
||||
expect(MAX_PARALLEL_LIMIT).not.toBe(MAX_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constants Validation', () => {
|
||||
it('should validate that required constants exist and have correct types', async () => {
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
const { MIN_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_PARALLEL_LIMIT).toBe('number')
|
||||
expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UserActionButtonType } from '@/app/components/workflow/nodes/human-inpu
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
import 'dayjs/locale/nl'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -45,6 +46,7 @@ const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'ja-JP': 'ja',
|
||||
'nl-NL': 'nl',
|
||||
}
|
||||
|
||||
export const getRelativeTime = (
|
||||
|
||||
@@ -98,7 +98,9 @@ const VoiceParamConfig = ({
|
||||
className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pl-3 pr-10 focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span className={cn('block truncate text-left text-text-secondary', !languageItem?.name && 'text-text-tertiary')}>
|
||||
{languageItem?.name ? t(`voice.language.${replace(languageItem?.value, '-', '')}`, { ns: 'common' }) : localLanguagePlaceholder}
|
||||
{languageItem?.name
|
||||
? t(`voice.language.${replace(languageItem?.value ?? '', '-', '')}`, languageItem?.name, { ns: 'common' as const })
|
||||
: localLanguagePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
@@ -129,7 +131,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('block', selected && 'font-normal')}
|
||||
>
|
||||
{t(`voice.language.${replace((item.value), '-', '')}`, { ns: 'common' })}
|
||||
{t(`voice.language.${replace((item.value), '-', '')}`, item.name, { ns: 'common' as const })}
|
||||
</span>
|
||||
{(selected || item.value === text2speech?.language) && (
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const InputTypeEnum = z.enum([
|
||||
'text-input',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ZodNumber, ZodSchema, ZodString } from 'zod'
|
||||
import type { BaseConfiguration } from './types'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { BaseFieldType } from './types'
|
||||
|
||||
export const generateZodSchema = (fields: BaseConfiguration[]) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const ContactMethod = z.union([
|
||||
z.literal('email'),
|
||||
@@ -22,10 +22,10 @@ export const UserSchema = z.object({
|
||||
.min(3, 'Surname must be at least 3 characters long')
|
||||
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
|
||||
isAcceptingTerms: z.boolean().refine(val => val, {
|
||||
message: 'You must accept the terms and conditions',
|
||||
error: 'You must accept the terms and conditions',
|
||||
}),
|
||||
contact: z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: z.email('Invalid email address'),
|
||||
phone: z.string().optional(),
|
||||
preferredContactMethod: ContactMethod,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ZodSchema, ZodString } from 'zod'
|
||||
import type { InputFieldConfiguration } from './types'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
|
||||
@@ -204,23 +204,10 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If we have a complete JSON structure but it doesn't parse,
|
||||
// it's likely an error rather than incomplete data
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
// Avoid executing arbitrary code; require valid JSON for chart options.
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,19 +236,9 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
isValidOption = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Both parsing methods failed, but content looks complete
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
}
|
||||
// Only accept JSON to avoid executing arbitrary code from the message.
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
}
|
||||
|
||||
if (isValidOption) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { env } from '@/env'
|
||||
import ParamItem from '.'
|
||||
|
||||
type Props = {
|
||||
@@ -11,12 +12,7 @@ type Props = {
|
||||
enable: boolean
|
||||
}
|
||||
|
||||
const maxTopK = (() => {
|
||||
const configValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-top-k-max-value') || '', 10)
|
||||
if (configValue && !isNaN(configValue))
|
||||
return configValue
|
||||
return 10
|
||||
})()
|
||||
const maxTopK = env.NEXT_PUBLIC_TOP_K_MAX_VALUE
|
||||
const VALUE_LIMIT = {
|
||||
default: 2,
|
||||
step: 1,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { Highlight } from '@/app/components/base/icons/src/public/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
const PremiumBadgeVariants = cva(
|
||||
'premium-badge',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
describe('withValidation HOC', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
// Sample components to wrap with validation
|
||||
@@ -65,7 +65,7 @@ const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
|
||||
// Create validated versions
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
email: z.email('Invalid email'),
|
||||
age: z.number().min(0).max(150),
|
||||
})
|
||||
|
||||
@@ -371,7 +371,7 @@ export const ConfigurationValidation: Story = {
|
||||
)
|
||||
|
||||
const configSchema = z.object({
|
||||
apiUrl: z.string().url('Must be valid URL'),
|
||||
apiUrl: z.url('Must be valid URL'),
|
||||
timeout: z.number().min(0).max(30000),
|
||||
retries: z.number().min(0).max(5),
|
||||
debug: z.boolean(),
|
||||
@@ -430,7 +430,7 @@ export const UsageDocumentation: Story = {
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||
{`import { z } from 'zod'
|
||||
{`import * as z from 'zod'
|
||||
import withValidation from './withValidation'
|
||||
|
||||
// Define your component
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
return <label className="text-xs font-semibold leading-none text-text-secondary">{props.children}</label>
|
||||
@@ -46,7 +47,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
const maxValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', 10)
|
||||
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ParentMode, PreProcessingRule, ProcessRule, Rules, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { env } from '@/env'
|
||||
import { ChunkingMode, ProcessMode } from '@/models/datasets'
|
||||
import escape from './escape'
|
||||
import unescape from './unescape'
|
||||
@@ -8,10 +9,7 @@ import unescape from './unescape'
|
||||
export const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n'
|
||||
export const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024
|
||||
export const DEFAULT_OVERLAP = 50
|
||||
export const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt(
|
||||
globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000',
|
||||
10,
|
||||
)
|
||||
export const MAXIMUM_CHUNK_TOKEN_LENGTH = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
|
||||
export type ParentChildConfig = {
|
||||
chunkForContext: ParentMode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Actions from './actions'
|
||||
@@ -53,7 +53,7 @@ const createFailingSchema = () => {
|
||||
issues: [{ path: ['field1'], message: 'is required' }],
|
||||
},
|
||||
}),
|
||||
} as unknown as z.ZodSchema
|
||||
} as unknown as z.ZodType
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DocType } from '@/models/datasets'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from '../style.module.css'
|
||||
|
||||
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
|
||||
return <div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
|
||||
}
|
||||
|
||||
const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
return (
|
||||
<Tooltip popupContent={metadataMap[type].text}>
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type DocTypeSelectorProps = {
|
||||
docType: DocType | ''
|
||||
documentType?: DocType | ''
|
||||
tempDocType: DocType | ''
|
||||
onTempDocTypeChange: (type: DocType | '') => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
docType,
|
||||
documentType,
|
||||
tempDocType,
|
||||
onTempDocTypeChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isFirstTime = !docType && !documentType
|
||||
const currValue = tempDocType ?? documentType
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFirstTime && (
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
{isFirstTime && (
|
||||
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group value={currValue ?? ''} onChange={onTempDocTypeChange} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<Radio key={type} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
{isFirstTime && (
|
||||
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
{documentType && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={onConfirm} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type DocumentTypeDisplayProps = {
|
||||
displayType: DocType | ''
|
||||
showChangeLink?: boolean
|
||||
onChangeClick?: () => void
|
||||
}
|
||||
|
||||
export const DocumentTypeDisplay: FC<DocumentTypeDisplayProps> = ({
|
||||
displayType,
|
||||
showChangeLink = false,
|
||||
onChangeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const effectiveType = displayType || 'book'
|
||||
|
||||
return (
|
||||
<div className={s.documentTypeShow}>
|
||||
{(displayType || !showChangeLink) && (
|
||||
<>
|
||||
<TypeIcon iconName={metadataMap[effectiveType]?.iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[effectiveType].text}
|
||||
{showChangeLink && (
|
||||
<div className="ml-1 inline-flex items-center gap-1">
|
||||
·
|
||||
<div onClick={onChangeClick} className="cursor-pointer hover:text-text-accent">
|
||||
{t('operation.change', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocTypeSelector
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { inputType } from '@/hooks/use-metadata'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { getTextWidthWithCanvas } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from '../style.module.css'
|
||||
|
||||
type FieldInfoProps = {
|
||||
label: string
|
||||
value?: string
|
||||
valueIcon?: ReactNode
|
||||
displayedValue?: string
|
||||
defaultValue?: string
|
||||
showEdit?: boolean
|
||||
inputType?: inputType
|
||||
selectOptions?: Array<{ value: string, name: string }>
|
||||
onUpdate?: (v: string) => void
|
||||
}
|
||||
|
||||
const FieldInfo: FC<FieldInfoProps> = ({
|
||||
label,
|
||||
value = '',
|
||||
valueIcon,
|
||||
displayedValue = '',
|
||||
defaultValue,
|
||||
showEdit = false,
|
||||
inputType = 'input',
|
||||
selectOptions = [],
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
|
||||
const editAlignTop = showEdit && inputType === 'textarea'
|
||||
const readAlignTop = !showEdit && textNeedWrap
|
||||
|
||||
const renderContent = () => {
|
||||
if (!showEdit)
|
||||
return displayedValue
|
||||
|
||||
if (inputType === 'select') {
|
||||
return (
|
||||
<SimpleSelect
|
||||
onSelect={({ value }) => onUpdate?.(value as string)}
|
||||
items={selectOptions}
|
||||
defaultValue={value}
|
||||
className={s.select}
|
||||
wrapperClassName={s.selectWrapper}
|
||||
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputType === 'textarea') {
|
||||
return (
|
||||
<AutoHeightTextarea
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
className={s.textArea}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
|
||||
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
|
||||
<div className="flex grow items-center gap-1 text-text-secondary">
|
||||
{valueIcon}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FieldInfo
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { metadataType } from '@/hooks/use-metadata'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
|
||||
import FieldInfo from './field-info'
|
||||
|
||||
const map2Options = (map: Record<string, string>) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
|
||||
}
|
||||
|
||||
function useCategoryMapResolver(mainField: metadataType | '') {
|
||||
const languageMap = useLanguages()
|
||||
const bookCategoryMap = useBookCategories()
|
||||
const personalDocCategoryMap = usePersonalDocCategories()
|
||||
const businessDocCategoryMap = useBusinessDocCategories()
|
||||
|
||||
return (field: string): Record<string, string> => {
|
||||
if (field === 'language')
|
||||
return languageMap
|
||||
if (field === 'category' && mainField === 'book')
|
||||
return bookCategoryMap
|
||||
if (field === 'document_type') {
|
||||
if (mainField === 'personal_document')
|
||||
return personalDocCategoryMap
|
||||
if (mainField === 'business_document')
|
||||
return businessDocCategoryMap
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
type MetadataFieldListProps = {
|
||||
mainField: metadataType | ''
|
||||
canEdit?: boolean
|
||||
metadata?: Record<string, string>
|
||||
docDetail?: FullDocumentDetail
|
||||
onFieldUpdate?: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
const MetadataFieldList: FC<MetadataFieldListProps> = ({
|
||||
mainField,
|
||||
canEdit = false,
|
||||
metadata,
|
||||
docDetail,
|
||||
onFieldUpdate,
|
||||
}) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
const getCategoryMap = useCategoryMapResolver(mainField)
|
||||
|
||||
if (!mainField)
|
||||
return null
|
||||
|
||||
const fieldMap = metadataMap[mainField]?.subFieldsMap
|
||||
const isFixedField = ['originInfo', 'technicalParameters'].includes(mainField)
|
||||
const sourceData = isFixedField ? docDetail : metadata
|
||||
|
||||
const getDisplayValue = (field: string) => {
|
||||
const val = get(sourceData, field, '')
|
||||
if (!val && val !== 0)
|
||||
return '-'
|
||||
if (fieldMap[field]?.inputType === 'select')
|
||||
return getCategoryMap(field)[val]
|
||||
if (fieldMap[field]?.render)
|
||||
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
|
||||
return val
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(fieldMap).map(field => (
|
||||
<FieldInfo
|
||||
key={fieldMap[field]?.label}
|
||||
label={fieldMap[field]?.label}
|
||||
displayedValue={getDisplayValue(field)}
|
||||
value={get(sourceData, field, '')}
|
||||
inputType={fieldMap[field]?.inputType || 'input'}
|
||||
showEdit={canEdit}
|
||||
onUpdate={val => onFieldUpdate?.(field, val)}
|
||||
selectOptions={map2Options(getCategoryMap(field))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataFieldList
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocType, FullDocumentDetail } from '@/models/datasets'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { modifyDocMetadata } from '@/service/datasets'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { useDocumentContext } from '../../context'
|
||||
|
||||
type MetadataState = {
|
||||
documentType?: DocType | ''
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw doc_type: treat 'others' as empty string.
|
||||
*/
|
||||
const normalizeDocType = (rawDocType: string): DocType | '' => {
|
||||
return rawDocType === 'others' ? '' : rawDocType as DocType | ''
|
||||
}
|
||||
|
||||
type UseMetadataStateOptions = {
|
||||
docDetail?: FullDocumentDetail
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export function useMetadataState({ docDetail, onUpdate }: UseMetadataStateOptions) {
|
||||
const { doc_metadata = {} } = docDetail || {}
|
||||
const rawDocType = docDetail?.doc_type ?? ''
|
||||
const docType = normalizeDocType(rawDocType)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const datasetId = useDocumentContext(s => s.datasetId)
|
||||
const documentId = useDocumentContext(s => s.documentId)
|
||||
|
||||
// If no documentType yet, start in editing + showDocTypes mode
|
||||
const [editStatus, setEditStatus] = useState(!docType)
|
||||
const [metadataParams, setMetadataParams] = useState<MetadataState>(
|
||||
docType
|
||||
? { documentType: docType, metadata: (doc_metadata || {}) as Record<string, string> }
|
||||
: { metadata: {} },
|
||||
)
|
||||
const [showDocTypes, setShowDocTypes] = useState(!docType)
|
||||
const [tempDocType, setTempDocType] = useState<DocType | ''>('')
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
// Sync local state when the upstream docDetail changes (e.g. after save or navigation).
|
||||
// These setters are intentionally called together to batch-reset multiple pieces
|
||||
// of derived editing state that cannot be expressed as pure derived values.
|
||||
useEffect(() => {
|
||||
if (docDetail?.doc_type) {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setEditStatus(false)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setShowDocTypes(false)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setTempDocType(docType)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setMetadataParams({
|
||||
documentType: docType,
|
||||
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
|
||||
})
|
||||
}
|
||||
}, [docDetail?.doc_type, docDetail?.doc_metadata, docType])
|
||||
|
||||
const confirmDocType = () => {
|
||||
if (!tempDocType)
|
||||
return
|
||||
setMetadataParams({
|
||||
documentType: tempDocType,
|
||||
// Clear metadata when switching to a different doc type
|
||||
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {},
|
||||
})
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
const cancelDocType = () => {
|
||||
setTempDocType(metadataParams.documentType ?? '')
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
const enableEdit = () => {
|
||||
setEditStatus(true)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setMetadataParams({ documentType: docType || '', metadata: { ...(docDetail?.doc_metadata || {}) } })
|
||||
setEditStatus(!docType)
|
||||
if (!docType)
|
||||
setShowDocTypes(true)
|
||||
}
|
||||
|
||||
const saveMetadata = async () => {
|
||||
setSaveLoading(true)
|
||||
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
|
||||
datasetId,
|
||||
documentId,
|
||||
body: {
|
||||
doc_type: metadataParams.documentType || docType || '',
|
||||
doc_metadata: metadataParams.metadata,
|
||||
},
|
||||
}) as Promise<CommonResponse>)
|
||||
if (!e)
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
else
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
onUpdate?.()
|
||||
setEditStatus(false)
|
||||
setSaveLoading(false)
|
||||
}
|
||||
|
||||
const updateMetadataField = (field: string, value: string) => {
|
||||
setMetadataParams(prev => ({ ...prev, metadata: { ...prev.metadata, [field]: value } }))
|
||||
}
|
||||
|
||||
return {
|
||||
docType,
|
||||
editStatus,
|
||||
showDocTypes,
|
||||
tempDocType,
|
||||
saveLoading,
|
||||
metadataParams,
|
||||
setTempDocType,
|
||||
setShowDocTypes,
|
||||
confirmDocType,
|
||||
cancelDocType,
|
||||
enableEdit,
|
||||
cancelEdit,
|
||||
saveMetadata,
|
||||
updateMetadataField,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Metadata, { FieldInfo } from './index'
|
||||
|
||||
// Mock document context
|
||||
@@ -121,7 +120,6 @@ vi.mock('@/hooks/use-metadata', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock getTextWidthWithCanvas
|
||||
vi.mock('@/utils', () => ({
|
||||
asyncRunSafe: async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
@@ -135,33 +133,32 @@ vi.mock('@/utils', () => ({
|
||||
getTextWidthWithCanvas: () => 100,
|
||||
}))
|
||||
|
||||
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
doc_type: 'book',
|
||||
doc_metadata: {
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
language: 'en',
|
||||
},
|
||||
data_source_type: 'upload_file',
|
||||
segment_count: 10,
|
||||
hit_count: 5,
|
||||
...overrides,
|
||||
} as FullDocumentDetail)
|
||||
|
||||
describe('Metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
doc_type: 'book',
|
||||
doc_metadata: {
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
language: 'en',
|
||||
},
|
||||
data_source_type: 'upload_file',
|
||||
segment_count: 10,
|
||||
hit_count: 5,
|
||||
...overrides,
|
||||
} as FullDocumentDetail)
|
||||
|
||||
const defaultProps = {
|
||||
docDetail: createMockDocDetail(),
|
||||
loading: false,
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
@@ -191,7 +188,7 @@ describe('Metadata', () => {
|
||||
// Arrange & Act
|
||||
render(<Metadata {...defaultProps} loading={true} />)
|
||||
|
||||
// Assert - Loading component should be rendered
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -204,7 +201,7 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Edit mode tests
|
||||
// Edit mode (tests useMetadataState hook integration)
|
||||
describe('Edit Mode', () => {
|
||||
it('should enter edit mode when edit button is clicked', () => {
|
||||
// Arrange
|
||||
@@ -303,7 +300,7 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Document type selection
|
||||
// Document type selection (tests DocTypeSelector sub-component integration)
|
||||
describe('Document Type Selection', () => {
|
||||
it('should show doc type selection when no doc_type exists', () => {
|
||||
// Arrange
|
||||
@@ -353,13 +350,13 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Origin info and technical parameters
|
||||
// Fixed fields (tests MetadataFieldList sub-component integration)
|
||||
describe('Fixed Fields', () => {
|
||||
it('should render origin info fields', () => {
|
||||
// Arrange & Act
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Assert - Origin info fields should be displayed
|
||||
// Assert
|
||||
expect(screen.getByText('Data Source Type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -382,7 +379,7 @@ describe('Metadata', () => {
|
||||
// Act
|
||||
const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
// Assert - should render without crashing
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -390,7 +387,7 @@ describe('Metadata', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />)
|
||||
|
||||
// Assert - should render without crashing
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -425,7 +422,6 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FieldInfo component tests
|
||||
describe('FieldInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -543,3 +539,149 @@ describe('FieldInfo', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- useMetadataState hook coverage tests (via component interactions) ---
|
||||
describe('useMetadataState coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
docDetail: createMockDocDetail(),
|
||||
loading: false,
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
describe('cancelDocType', () => {
|
||||
it('should cancel doc type change and return to edit mode', () => {
|
||||
// Arrange
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode → click change to open doc type selector
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
// Now in doc type selector mode — should show cancel button
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
|
||||
// Act — cancel the doc type change
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert — should be back to edit mode (cancel + save buttons visible)
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmDocType', () => {
|
||||
it('should confirm same doc type and return to edit mode keeping metadata', () => {
|
||||
// Arrange — useEffect syncs tempDocType='book' from docDetail
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode → click change to open doc type selector
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
// DocTypeSelector shows save/cancel buttons
|
||||
expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument()
|
||||
|
||||
// Act — click save to confirm same doc type (tempDocType='book')
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
// Assert — should return to edit mode with metadata fields visible
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelEdit when no docType', () => {
|
||||
it('should show doc type selection when cancel is clicked with doc_type others', () => {
|
||||
// Arrange — doc with 'others' type normalizes to '' internally.
|
||||
// The useEffect sees doc_type='others' (truthy) and syncs state,
|
||||
// so the component initially shows view mode. Enter edit → cancel to trigger cancelEdit.
|
||||
const docDetail = createMockDocDetail({ doc_type: 'others' })
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
// 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode
|
||||
// The rendered type uses default 'book' fallback for display
|
||||
expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument()
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
|
||||
// Act — cancel edit; internally docType is '' so cancelEdit goes to showDocTypes
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert — should show doc type selection since normalized docType was ''
|
||||
expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMetadataField', () => {
|
||||
it('should update metadata field value via input', () => {
|
||||
// Arrange
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
// Act — find an input and change its value (Title field)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
fireEvent.change(inputs[0], { target: { value: 'Updated Title' } })
|
||||
|
||||
// Assert — the input should have the new value
|
||||
expect(inputs[0]).toHaveValue('Updated Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveMetadata calls modifyDocMetadata with correct body', () => {
|
||||
it('should pass doc_type and doc_metadata in save request', async () => {
|
||||
// Arrange
|
||||
mockModifyDocMetadata.mockResolvedValueOnce({})
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
// Act — save
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockModifyDocMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
datasetId: 'test-dataset-id',
|
||||
documentId: 'test-document-id',
|
||||
body: expect.objectContaining({
|
||||
doc_type: 'book',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEffect sync', () => {
|
||||
it('should handle doc_metadata being null in effect sync', () => {
|
||||
// Arrange — first render with null metadata
|
||||
const { rerender } = render(
|
||||
<Metadata
|
||||
{...defaultProps}
|
||||
docDetail={createMockDocDetail({ doc_metadata: null })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act — rerender with a different doc_type to trigger useEffect sync
|
||||
rerender(
|
||||
<Metadata
|
||||
{...defaultProps}
|
||||
docDetail={createMockDocDetail({ doc_type: 'paper', doc_metadata: null })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert — should render without crashing, showing Paper type
|
||||
expect(screen.getByText('Paper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,422 +1,124 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { inputType, metadataType } from '@/hooks/use-metadata'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocType, FullDocumentDetail } from '@/models/datasets'
|
||||
import type { FC } from 'react'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { PencilIcon } from '@heroicons/react/24/outline'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import { modifyDocMetadata } from '@/service/datasets'
|
||||
import { asyncRunSafe, getTextWidthWithCanvas } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useDocumentContext } from '../context'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import DocTypeSelector, { DocumentTypeDisplay } from './components/doc-type-selector'
|
||||
import MetadataFieldList from './components/metadata-field-list'
|
||||
import { useMetadataState } from './hooks/use-metadata-state'
|
||||
import s from './style.module.css'
|
||||
|
||||
const map2Options = (map: { [key: string]: string }) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
|
||||
}
|
||||
export { default as FieldInfo } from './components/field-info'
|
||||
|
||||
type IFieldInfoProps = {
|
||||
label: string
|
||||
value?: string
|
||||
valueIcon?: ReactNode
|
||||
displayedValue?: string
|
||||
defaultValue?: string
|
||||
showEdit?: boolean
|
||||
inputType?: inputType
|
||||
selectOptions?: Array<{ value: string, name: string }>
|
||||
onUpdate?: (v: any) => void
|
||||
}
|
||||
|
||||
export const FieldInfo: FC<IFieldInfoProps> = ({
|
||||
label,
|
||||
value = '',
|
||||
valueIcon,
|
||||
displayedValue = '',
|
||||
defaultValue,
|
||||
showEdit = false,
|
||||
inputType = 'input',
|
||||
selectOptions = [],
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
|
||||
const editAlignTop = showEdit && inputType === 'textarea'
|
||||
const readAlignTop = !showEdit && textNeedWrap
|
||||
|
||||
const renderContent = () => {
|
||||
if (!showEdit)
|
||||
return displayedValue
|
||||
|
||||
if (inputType === 'select') {
|
||||
return (
|
||||
<SimpleSelect
|
||||
onSelect={({ value }) => onUpdate?.(value as string)}
|
||||
items={selectOptions}
|
||||
defaultValue={value}
|
||||
className={s.select}
|
||||
wrapperClassName={s.selectWrapper}
|
||||
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputType === 'textarea') {
|
||||
return (
|
||||
<AutoHeightTextarea
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
className={s.textArea}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
|
||||
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
|
||||
<div className="flex grow items-center gap-1 text-text-secondary">
|
||||
{valueIcon}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
|
||||
return (
|
||||
<div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
|
||||
)
|
||||
}
|
||||
|
||||
const IconButton: FC<{
|
||||
type: DocType
|
||||
isChecked: boolean
|
||||
}> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={metadataMap[type].text}
|
||||
>
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type IMetadataProps = {
|
||||
type MetadataProps = {
|
||||
docDetail?: FullDocumentDetail
|
||||
loading: boolean
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
type MetadataState = {
|
||||
documentType?: DocType | ''
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
const Metadata: FC<IMetadataProps> = ({ docDetail, loading, onUpdate }) => {
|
||||
const { doc_metadata = {} } = docDetail || {}
|
||||
const rawDocType = docDetail?.doc_type ?? ''
|
||||
const doc_type = rawDocType === 'others' ? '' : rawDocType
|
||||
|
||||
const Metadata: FC<MetadataProps> = ({ docDetail, loading, onUpdate }) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const languageMap = useLanguages()
|
||||
const bookCategoryMap = useBookCategories()
|
||||
const personalDocCategoryMap = usePersonalDocCategories()
|
||||
const businessDocCategoryMap = useBusinessDocCategories()
|
||||
const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default
|
||||
// the initial values are according to the documentType
|
||||
const [metadataParams, setMetadataParams] = useState<MetadataState>(
|
||||
doc_type
|
||||
? {
|
||||
documentType: doc_type as DocType,
|
||||
metadata: (doc_metadata || {}) as Record<string, string>,
|
||||
}
|
||||
: { metadata: {} },
|
||||
)
|
||||
const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types
|
||||
const [tempDocType, setTempDocType] = useState<DocType | ''>('') // for remember icon click
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
const { notify } = useContext(ToastContext)
|
||||
const datasetId = useDocumentContext(s => s.datasetId)
|
||||
const documentId = useDocumentContext(s => s.documentId)
|
||||
|
||||
useEffect(() => {
|
||||
if (docDetail?.doc_type) {
|
||||
setEditStatus(false)
|
||||
setShowDocTypes(false)
|
||||
setTempDocType(doc_type as DocType | '')
|
||||
setMetadataParams({
|
||||
documentType: doc_type as DocType | '',
|
||||
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
|
||||
})
|
||||
}
|
||||
}, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type])
|
||||
|
||||
// confirm doc type
|
||||
const confirmDocType = () => {
|
||||
if (!tempDocType)
|
||||
return
|
||||
setMetadataParams({
|
||||
documentType: tempDocType,
|
||||
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} as Record<string, string>, // change doc type, clear metadata
|
||||
})
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
// cancel doc type
|
||||
const cancelDocType = () => {
|
||||
setTempDocType(metadataParams.documentType ?? '')
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
// show doc type select
|
||||
const renderSelectDocType = () => {
|
||||
const { documentType } = metadataParams
|
||||
const {
|
||||
docType,
|
||||
editStatus,
|
||||
showDocTypes,
|
||||
tempDocType,
|
||||
saveLoading,
|
||||
metadataParams,
|
||||
setTempDocType,
|
||||
setShowDocTypes,
|
||||
confirmDocType,
|
||||
cancelDocType,
|
||||
enableEdit,
|
||||
cancelEdit,
|
||||
saveMetadata,
|
||||
updateMetadataField,
|
||||
} = useMetadataState({ docDetail, onUpdate })
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{!doc_type && !documentType && (
|
||||
<>
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
</>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
{!doc_type && !documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
{documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group value={tempDocType ?? documentType ?? ''} onChange={setTempDocType} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map((type, index) => {
|
||||
const currValue = tempDocType ?? documentType
|
||||
return (
|
||||
<Radio key={index} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton
|
||||
type={type}
|
||||
isChecked={currValue === type}
|
||||
/>
|
||||
</Radio>
|
||||
)
|
||||
})}
|
||||
</Radio.Group>
|
||||
{!doc_type && !documentType && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmDocType}
|
||||
disabled={!tempDocType}
|
||||
>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
{documentType && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={confirmDocType} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">{t('operation.save', { ns: 'common' })}</Button>
|
||||
<Button onClick={cancelDocType} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// show metadata info and edit
|
||||
const renderFieldInfos = ({ mainField = 'book', canEdit }: { mainField?: metadataType | '', canEdit?: boolean }) => {
|
||||
if (!mainField)
|
||||
return null
|
||||
const fieldMap = metadataMap[mainField]?.subFieldsMap
|
||||
const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata
|
||||
|
||||
const getTargetMap = (field: string) => {
|
||||
if (field === 'language')
|
||||
return languageMap
|
||||
if (field === 'category' && mainField === 'book')
|
||||
return bookCategoryMap
|
||||
|
||||
if (field === 'document_type') {
|
||||
if (mainField === 'personal_document')
|
||||
return personalDocCategoryMap
|
||||
if (mainField === 'business_document')
|
||||
return businessDocCategoryMap
|
||||
}
|
||||
return {} as any
|
||||
}
|
||||
|
||||
const getTargetValue = (field: string) => {
|
||||
const val = get(sourceData, field, '')
|
||||
if (!val && val !== 0)
|
||||
return '-'
|
||||
if (fieldMap[field]?.inputType === 'select')
|
||||
return getTargetMap(field)[val]
|
||||
if (fieldMap[field]?.render)
|
||||
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
|
||||
return val
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(fieldMap).map((field) => {
|
||||
return (
|
||||
<FieldInfo
|
||||
key={fieldMap[field]?.label}
|
||||
label={fieldMap[field]?.label}
|
||||
displayedValue={getTargetValue(field)}
|
||||
value={get(sourceData, field, '')}
|
||||
inputType={fieldMap[field]?.inputType || 'input'}
|
||||
showEdit={canEdit}
|
||||
onUpdate={(val) => {
|
||||
setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } }))
|
||||
}}
|
||||
selectOptions={map2Options(getTargetMap(field))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className={`${s.main} bg-gray-25`}>
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const enabledEdit = () => {
|
||||
setEditStatus(true)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setMetadataParams({ documentType: doc_type || '', metadata: { ...docDetail?.doc_metadata } })
|
||||
setEditStatus(!doc_type)
|
||||
if (!doc_type)
|
||||
setShowDocTypes(true)
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
setSaveLoading(true)
|
||||
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
|
||||
datasetId,
|
||||
documentId,
|
||||
body: {
|
||||
doc_type: metadataParams.documentType || doc_type || '',
|
||||
doc_metadata: metadataParams.metadata,
|
||||
},
|
||||
}) as Promise<CommonResponse>)
|
||||
if (!e)
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
else
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
onUpdate?.()
|
||||
setEditStatus(false)
|
||||
setSaveLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.main} ${editStatus ? 'bg-white' : 'bg-gray-25'}`}>
|
||||
{loading
|
||||
? (<Loading type="app" />)
|
||||
: (
|
||||
<>
|
||||
<div className={s.titleWrapper}>
|
||||
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
|
||||
{!editStatus
|
||||
? (
|
||||
<Button onClick={enabledEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
|
||||
<PencilIcon className={s.opIcon} />
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
className={`${s.opBtn} ${s.opSaveBtn}`}
|
||||
variant="primary"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Header: title + action buttons */}
|
||||
<div className={s.titleWrapper}>
|
||||
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
|
||||
{!editStatus
|
||||
? (
|
||||
<Button onClick={enableEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
|
||||
<PencilIcon className={s.opIcon} />
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: !showDocTypes && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={cancelEdit} className={`${s.opBtn} ${s.opCancelBtn}`}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button onClick={saveMetadata} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary" loading={saveLoading}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
{/* show selected doc type and changing entry */}
|
||||
{!editStatus
|
||||
? (
|
||||
<div className={s.documentTypeShow}>
|
||||
<TypeIcon iconName={metadataMap[doc_type || 'book']?.iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[doc_type || 'book'].text}
|
||||
</div>
|
||||
)
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<div className={s.documentTypeShow}>
|
||||
{metadataParams.documentType && (
|
||||
<>
|
||||
<TypeIcon iconName={metadataMap[metadataParams.documentType || 'book'].iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[metadataParams.documentType || 'book'].text}
|
||||
{editStatus && (
|
||||
<div className="ml-1 inline-flex items-center gap-1">
|
||||
·
|
||||
<div
|
||||
onClick={() => { setShowDocTypes(true) }}
|
||||
className="cursor-pointer hover:text-text-accent"
|
||||
>
|
||||
{t('operation.change', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!doc_type && showDocTypes) ? null : <Divider />}
|
||||
{showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })}
|
||||
{/* show fixed fields */}
|
||||
<Divider />
|
||||
{renderFieldInfos({ mainField: 'originInfo', canEdit: false })}
|
||||
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
|
||||
<Divider />
|
||||
{renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document type display / selector */}
|
||||
{!editStatus
|
||||
? <DocumentTypeDisplay displayType={docType} />
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<DocumentTypeDisplay
|
||||
displayType={metadataParams.documentType || ''}
|
||||
showChangeLink={editStatus}
|
||||
onChangeClick={() => setShowDocTypes(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Divider between type display and fields (skip when in first-time selection) */}
|
||||
{(!docType && showDocTypes) ? null : <Divider />}
|
||||
|
||||
{/* Doc type selector or editable metadata fields */}
|
||||
{showDocTypes
|
||||
? (
|
||||
<DocTypeSelector
|
||||
docType={docType}
|
||||
documentType={metadataParams.documentType}
|
||||
tempDocType={tempDocType}
|
||||
onTempDocTypeChange={setTempDocType}
|
||||
onConfirm={confirmDocType}
|
||||
onCancel={cancelDocType}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<MetadataFieldList
|
||||
mainField={metadataParams.documentType || ''}
|
||||
canEdit={editStatus}
|
||||
metadata={metadataParams.metadata}
|
||||
docDetail={docDetail}
|
||||
onFieldUpdate={updateMetadataField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fixed fields: origin info */}
|
||||
<Divider />
|
||||
<MetadataFieldList mainField="originInfo" docDetail={docDetail} />
|
||||
|
||||
{/* Fixed fields: technical parameters */}
|
||||
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
|
||||
<Divider />
|
||||
<MetadataFieldList mainField="technicalParameters" docDetail={docDetail} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import ApiServer from './ApiServer'
|
||||
import ApiServer from '../ApiServer'
|
||||
|
||||
// Mock the secret-key-modal since it involves complex API interactions
|
||||
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (
|
||||
isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>Close Modal</button></div> : null
|
||||
@@ -38,7 +37,6 @@ describe('ApiServer', () => {
|
||||
|
||||
it('should render CopyFeedback component', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
// CopyFeedback renders a button for copying
|
||||
const copyButtons = screen.getAllByRole('button')
|
||||
expect(copyButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -90,7 +88,6 @@ describe('ApiServer', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ApiServer {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Open modal
|
||||
const apiKeyButton = screen.getByText('appApi.apiKey')
|
||||
await act(async () => {
|
||||
await user.click(apiKeyButton)
|
||||
@@ -98,7 +95,6 @@ describe('ApiServer', () => {
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByText('Close Modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
@@ -196,9 +192,7 @@ describe('ApiServer', () => {
|
||||
describe('SecretKeyButton styling', () => {
|
||||
it('should have shrink-0 class to prevent shrinking', () => {
|
||||
render(<ApiServer {...defaultProps} appId="app-123" />)
|
||||
// The SecretKeyButton wraps a Button component
|
||||
const button = screen.getByRole('button', { name: /apiKey/i })
|
||||
// Check parent container has shrink-0
|
||||
const buttonContainer = button.closest('.shrink-0')
|
||||
expect(buttonContainer).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Code, CodeGroup, Embed, Pre } from './code'
|
||||
import { Code, CodeGroup, Embed, Pre } from '../code'
|
||||
|
||||
// Mock the clipboard utility
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
@@ -155,6 +154,9 @@ describe('code.tsx components', () => {
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
const tab2 = screen.getByRole('tab', { name: 'Tab2' })
|
||||
await act(async () => {
|
||||
@@ -229,7 +231,6 @@ describe('code.tsx components', () => {
|
||||
)
|
||||
expect(screen.getByText('POST')).toBeInTheDocument()
|
||||
expect(screen.getByText('/api/create')).toBeInTheDocument()
|
||||
// Separator should be present
|
||||
const separator = container.querySelector('.rounded-full.bg-zinc-500')
|
||||
expect(separator).toBeInTheDocument()
|
||||
})
|
||||
@@ -264,6 +265,9 @@ describe('code.tsx components', () => {
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
@@ -285,6 +289,9 @@ describe('code.tsx components', () => {
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
@@ -295,7 +302,6 @@ describe('code.tsx components', () => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Advance time past the timeout
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
@@ -358,7 +364,6 @@ describe('code.tsx components', () => {
|
||||
<pre><code>code content</code></pre>
|
||||
</Pre>,
|
||||
)
|
||||
// Should render within a CodeGroup structure
|
||||
const codeGroup = container.querySelector('.bg-zinc-900')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
})
|
||||
@@ -382,7 +387,6 @@ describe('code.tsx components', () => {
|
||||
</Pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
// The outer code should be rendered (from targetCode)
|
||||
expect(screen.getByText('outer code')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -546,7 +550,6 @@ describe('code.tsx components', () => {
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
// Should render copy button even with empty code
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -569,7 +572,6 @@ line3`
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
// Multiline code should be rendered - use a partial match
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/line2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/line3/)).toBeInTheDocument()
|
||||
206
web/app/components/develop/__tests__/doc.spec.tsx
Normal file
206
web/app/components/develop/__tests__/doc.spec.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
import Doc from '../doc'
|
||||
|
||||
// The vitest mdx-stub plugin makes .mdx files parseable; these mocks replace
|
||||
vi.mock('../template/template.en.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-completion-en" />,
|
||||
}))
|
||||
vi.mock('../template/template.zh.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-completion-zh" />,
|
||||
}))
|
||||
vi.mock('../template/template.ja.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-completion-ja" />,
|
||||
}))
|
||||
vi.mock('../template/template_chat.en.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-chat-en" />,
|
||||
}))
|
||||
vi.mock('../template/template_chat.zh.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-chat-zh" />,
|
||||
}))
|
||||
vi.mock('../template/template_chat.ja.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-chat-ja" />,
|
||||
}))
|
||||
vi.mock('../template/template_advanced_chat.en.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-en" />,
|
||||
}))
|
||||
vi.mock('../template/template_advanced_chat.zh.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-zh" />,
|
||||
}))
|
||||
vi.mock('../template/template_advanced_chat.ja.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-ja" />,
|
||||
}))
|
||||
vi.mock('../template/template_workflow.en.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-en" />,
|
||||
}))
|
||||
vi.mock('../template/template_workflow.zh.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-zh" />,
|
||||
}))
|
||||
vi.mock('../template/template_workflow.ja.mdx', () => ({
|
||||
default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-ja" />,
|
||||
}))
|
||||
|
||||
const mockLocale = vi.fn().mockReturnValue('en-US')
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale(),
|
||||
}))
|
||||
|
||||
const mockTheme = vi.fn().mockReturnValue(Theme.light)
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mockTheme() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
|
||||
}))
|
||||
|
||||
describe('Doc', () => {
|
||||
const makeAppDetail = (mode: AppModeEnum, variables: Array<{ key: string, name: string }> = []) => ({
|
||||
mode,
|
||||
model_config: {
|
||||
configs: {
|
||||
prompt_variables: variables,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocale.mockReturnValue('en-US')
|
||||
mockTheme.mockReturnValue(Theme.light)
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: false }),
|
||||
})
|
||||
})
|
||||
|
||||
describe('template selection by app mode', () => {
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'template-chat-en'],
|
||||
[AppModeEnum.AGENT_CHAT, 'template-chat-en'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'template-advanced-chat-en'],
|
||||
[AppModeEnum.WORKFLOW, 'template-workflow-en'],
|
||||
[AppModeEnum.COMPLETION, 'template-completion-en'],
|
||||
])('should render correct EN template for mode %s', (mode, testId) => {
|
||||
render(<Doc appDetail={makeAppDetail(mode)} />)
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('template selection by locale', () => {
|
||||
it('should render ZH template when locale is zh-Hans', () => {
|
||||
mockLocale.mockReturnValue('zh-Hans')
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
expect(screen.getByTestId('template-chat-zh')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render JA template when locale is ja-JP', () => {
|
||||
mockLocale.mockReturnValue('ja-JP')
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
expect(screen.getByTestId('template-chat-ja')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to EN template for unsupported locales', () => {
|
||||
mockLocale.mockReturnValue('fr-FR')
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.COMPLETION)} />)
|
||||
expect(screen.getByTestId('template-completion-en')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ZH advanced-chat template', () => {
|
||||
mockLocale.mockReturnValue('zh-Hans')
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.ADVANCED_CHAT)} />)
|
||||
expect(screen.getByTestId('template-advanced-chat-zh')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render JA workflow template', () => {
|
||||
mockLocale.mockReturnValue('ja-JP')
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.WORKFLOW)} />)
|
||||
expect(screen.getByTestId('template-workflow-ja')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('null/undefined appDetail', () => {
|
||||
it('should render nothing when appDetail has no mode', () => {
|
||||
render(<Doc appDetail={{}} />)
|
||||
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('template-chat-en')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when appDetail is null', () => {
|
||||
render(<Doc appDetail={null} />)
|
||||
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TOC toggle', () => {
|
||||
it('should show collapsed TOC button by default on small screens', () => {
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show expanded TOC on wide screens', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
})
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand TOC when toggle button is clicked', async () => {
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
const toggleBtn = screen.getByLabelText('Open table of contents')
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleBtn)
|
||||
})
|
||||
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse TOC when close button is clicked', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
})
|
||||
render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
|
||||
const closeBtn = screen.getByLabelText('Close')
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn)
|
||||
})
|
||||
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dark theme', () => {
|
||||
it('should apply prose-invert class in dark mode', () => {
|
||||
mockTheme.mockReturnValue(Theme.dark)
|
||||
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
const article = container.querySelector('article')
|
||||
expect(article?.className).toContain('prose-invert')
|
||||
})
|
||||
|
||||
it('should not apply prose-invert class in light mode', () => {
|
||||
mockTheme.mockReturnValue(Theme.light)
|
||||
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
const article = container.querySelector('article')
|
||||
expect(article?.className).not.toContain('prose-invert')
|
||||
})
|
||||
})
|
||||
|
||||
describe('article structure', () => {
|
||||
it('should render article with prose classes', () => {
|
||||
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.COMPLETION)} />)
|
||||
const article = container.querySelector('article')
|
||||
expect(article).toBeInTheDocument()
|
||||
expect(article?.className).toContain('prose')
|
||||
})
|
||||
|
||||
it('should render flex layout wrapper', () => {
|
||||
const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />)
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import DevelopMain from './index'
|
||||
import DevelopMain from '../index'
|
||||
|
||||
// Mock the app store with a factory function to control state
|
||||
const mockAppDetailValue: { current: unknown } = { current: undefined }
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: unknown) => unknown) => {
|
||||
@@ -10,7 +9,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the Doc component since it has complex dependencies
|
||||
vi.mock('@/app/components/develop/doc', () => ({
|
||||
default: ({ appDetail }: { appDetail: { name?: string } | null }) => (
|
||||
<div data-testid="doc-component">
|
||||
@@ -20,7 +18,6 @@ vi.mock('@/app/components/develop/doc', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock the ApiServer component
|
||||
vi.mock('@/app/components/develop/ApiServer', () => ({
|
||||
default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => (
|
||||
<div data-testid="api-server">
|
||||
@@ -44,7 +41,6 @@ describe('DevelopMain', () => {
|
||||
mockAppDetailValue.current = undefined
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
|
||||
// Loading component renders with role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -128,27 +124,27 @@ describe('DevelopMain', () => {
|
||||
})
|
||||
|
||||
it('should have flex column layout', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = screen.getByTestId('develop-main')
|
||||
expect(mainContainer.className).toContain('flex')
|
||||
expect(mainContainer.className).toContain('flex-col')
|
||||
})
|
||||
|
||||
it('should have relative positioning', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = screen.getByTestId('develop-main')
|
||||
expect(mainContainer.className).toContain('relative')
|
||||
})
|
||||
|
||||
it('should have full height', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = screen.getByTestId('develop-main')
|
||||
expect(mainContainer.className).toContain('h-full')
|
||||
})
|
||||
|
||||
it('should have overflow-hidden', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = screen.getByTestId('develop-main')
|
||||
expect(mainContainer.className).toContain('overflow-hidden')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from './md'
|
||||
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from '../md'
|
||||
|
||||
describe('md.tsx components', () => {
|
||||
describe('Heading', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Tag } from './tag'
|
||||
import { Tag } from '../tag'
|
||||
|
||||
describe('Tag', () => {
|
||||
describe('rendering', () => {
|
||||
@@ -110,7 +110,6 @@ describe('Tag', () => {
|
||||
it('should apply small variant styles', () => {
|
||||
render(<Tag variant="small">GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
// Small variant should not have ring styles
|
||||
expect(tag.className).not.toContain('rounded-lg')
|
||||
expect(tag.className).not.toContain('ring-1')
|
||||
})
|
||||
@@ -189,7 +188,6 @@ describe('Tag', () => {
|
||||
render(<Tag color="emerald" variant="small">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('text-emerald-500')
|
||||
// Small variant should not have background/ring styles
|
||||
expect(tag.className).not.toContain('bg-emerald-400/10')
|
||||
expect(tag.className).not.toContain('ring-emerald-300')
|
||||
})
|
||||
@@ -223,7 +221,6 @@ describe('Tag', () => {
|
||||
it('should correctly map PATCH to emerald (default)', () => {
|
||||
render(<Tag>PATCH</Tag>)
|
||||
const tag = screen.getByText('PATCH')
|
||||
// PATCH is not in the valueColorMap, so it defaults to emerald
|
||||
expect(tag.className).toContain('text-emerald')
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ const DevelopMain = ({ appId }: IDevelopMainProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden">
|
||||
<div data-testid="develop-main" className="relative flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-solid border-b-divider-regular px-6 py-2">
|
||||
<div className="text-lg font-medium text-text-primary"></div>
|
||||
<ApiServer apiBaseUrl={appDetail.api_base_url} appId={appId} />
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import InputCopy from './input-copy'
|
||||
import InputCopy from '../input-copy'
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
async function renderAndFlush(ui: React.ReactElement) {
|
||||
const result = render(ui)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
describe('InputCopy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -20,19 +27,18 @@ describe('InputCopy', () => {
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the value', () => {
|
||||
render(<InputCopy value="test-api-key-12345" />)
|
||||
it('should render the value', async () => {
|
||||
await renderAndFlush(<InputCopy value="test-api-key-12345" />)
|
||||
expect(screen.getByText('test-api-key-12345')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty value by default', () => {
|
||||
render(<InputCopy />)
|
||||
// Empty string should be rendered
|
||||
it('should render with empty value by default', async () => {
|
||||
await renderAndFlush(<InputCopy />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children when provided', () => {
|
||||
render(
|
||||
it('should render children when provided', async () => {
|
||||
await renderAndFlush(
|
||||
<InputCopy value="key">
|
||||
<span data-testid="custom-child">Custom Content</span>
|
||||
</InputCopy>,
|
||||
@@ -40,53 +46,52 @@ describe('InputCopy', () => {
|
||||
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CopyFeedback component', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
// CopyFeedback should render a button
|
||||
it('should render CopyFeedback component', async () => {
|
||||
await renderAndFlush(<InputCopy value="test" />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<InputCopy value="test" className="custom-class" />)
|
||||
it('should apply custom className', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" className="custom-class" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('should have flex layout', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have flex layout', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('flex')
|
||||
})
|
||||
|
||||
it('should have items-center alignment', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have items-center alignment', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('items-center')
|
||||
})
|
||||
|
||||
it('should have rounded-lg class', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have rounded-lg class', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('should have background class', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have background class', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('bg-components-input-bg-normal')
|
||||
})
|
||||
|
||||
it('should have hover state', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have hover state', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('hover:bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should have py-2 padding', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have py-2 padding', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('py-2')
|
||||
})
|
||||
@@ -95,7 +100,7 @@ describe('InputCopy', () => {
|
||||
describe('copy functionality', () => {
|
||||
it('should copy value when clicked', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="copy-this-value" />)
|
||||
await renderAndFlush(<InputCopy value="copy-this-value" />)
|
||||
|
||||
const copyableArea = screen.getByText('copy-this-value')
|
||||
await act(async () => {
|
||||
@@ -107,20 +112,19 @@ describe('InputCopy', () => {
|
||||
|
||||
it('should update copied state after clicking', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="test-value" />)
|
||||
await renderAndFlush(<InputCopy value="test-value" />)
|
||||
|
||||
const copyableArea = screen.getByText('test-value')
|
||||
await act(async () => {
|
||||
await user.click(copyableArea)
|
||||
})
|
||||
|
||||
// Copy function should have been called
|
||||
expect(copy).toHaveBeenCalledWith('test-value')
|
||||
})
|
||||
|
||||
it('should reset copied state after timeout', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="test-value" />)
|
||||
await renderAndFlush(<InputCopy value="test-value" />)
|
||||
|
||||
const copyableArea = screen.getByText('test-value')
|
||||
await act(async () => {
|
||||
@@ -129,32 +133,29 @@ describe('InputCopy', () => {
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('test-value')
|
||||
|
||||
// Advance time to reset the copied state
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
|
||||
// Component should still be functional
|
||||
expect(screen.getByText('test-value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip on value', () => {
|
||||
render(<InputCopy value="test-value" />)
|
||||
// Value should be wrapped in tooltip (tooltip shows on hover, not as visible text)
|
||||
it('should render tooltip on value', async () => {
|
||||
await renderAndFlush(<InputCopy value="test-value" />)
|
||||
const valueText = screen.getByText('test-value')
|
||||
expect(valueText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('should render tooltip wrapper', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
it('should render tooltip wrapper', async () => {
|
||||
await renderAndFlush(<InputCopy value="test" />)
|
||||
const valueText = screen.getByText('test')
|
||||
expect(valueText).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have cursor-pointer on clickable area', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
it('should have cursor-pointer on clickable area', async () => {
|
||||
await renderAndFlush(<InputCopy value="test" />)
|
||||
const valueText = screen.getByText('test')
|
||||
const clickableArea = valueText.closest('div[class*="cursor-pointer"]')
|
||||
expect(clickableArea).toBeInTheDocument()
|
||||
@@ -162,42 +163,42 @@ describe('InputCopy', () => {
|
||||
})
|
||||
|
||||
describe('divider', () => {
|
||||
it('should render vertical divider', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should render vertical divider', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct divider dimensions', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have correct divider dimensions', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider?.className).toContain('h-4')
|
||||
expect(divider?.className).toContain('w-px')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on divider', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have shrink-0 on divider', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider?.className).toContain('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('value display', () => {
|
||||
it('should have truncate class for long values', () => {
|
||||
render(<InputCopy value="very-long-api-key-value-that-might-overflow" />)
|
||||
it('should have truncate class for long values', async () => {
|
||||
await renderAndFlush(<InputCopy value="very-long-api-key-value-that-might-overflow" />)
|
||||
const valueText = screen.getByText('very-long-api-key-value-that-might-overflow')
|
||||
const container = valueText.closest('div[class*="truncate"]')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have text-secondary color on value', () => {
|
||||
render(<InputCopy value="test-value" />)
|
||||
it('should have text-secondary color on value', async () => {
|
||||
await renderAndFlush(<InputCopy value="test-value" />)
|
||||
const valueText = screen.getByText('test-value')
|
||||
expect(valueText.className).toContain('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should have absolute positioning for overlay', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
it('should have absolute positioning for overlay', async () => {
|
||||
await renderAndFlush(<InputCopy value="test" />)
|
||||
const valueText = screen.getByText('test')
|
||||
const container = valueText.closest('div[class*="absolute"]')
|
||||
expect(container).toBeInTheDocument()
|
||||
@@ -205,22 +206,22 @@ describe('InputCopy', () => {
|
||||
})
|
||||
|
||||
describe('inner container', () => {
|
||||
it('should have grow class on inner container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have grow class on inner container', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const innerContainer = container.querySelector('.grow')
|
||||
expect(innerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have h-5 height on inner container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have h-5 height on inner container', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const innerContainer = container.querySelector('.h-5')
|
||||
expect(innerContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with children', () => {
|
||||
it('should render children before value', () => {
|
||||
const { container } = render(
|
||||
it('should render children before value', async () => {
|
||||
const { container } = await renderAndFlush(
|
||||
<InputCopy value="key">
|
||||
<span data-testid="prefix">Prefix:</span>
|
||||
</InputCopy>,
|
||||
@@ -229,8 +230,8 @@ describe('InputCopy', () => {
|
||||
expect(children).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both children and value', () => {
|
||||
render(
|
||||
it('should render both children and value', async () => {
|
||||
await renderAndFlush(
|
||||
<InputCopy value="api-key">
|
||||
<span>Label:</span>
|
||||
</InputCopy>,
|
||||
@@ -241,55 +242,53 @@ describe('InputCopy', () => {
|
||||
})
|
||||
|
||||
describe('CopyFeedback section', () => {
|
||||
it('should have margin on CopyFeedback container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have margin on CopyFeedback container', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const copyFeedbackContainer = container.querySelector('.mx-1')
|
||||
expect(copyFeedbackContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('relative container', () => {
|
||||
it('should have relative positioning on value container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have relative positioning on value container', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const relativeContainer = container.querySelector('.relative')
|
||||
expect(relativeContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have grow on value container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
// Find the relative container that also has grow
|
||||
it('should have grow on value container', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const valueContainer = container.querySelector('.relative.grow')
|
||||
expect(valueContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have full height on value container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
it('should have full height on value container', async () => {
|
||||
const { container } = await renderAndFlush(<InputCopy value="test" />)
|
||||
const valueContainer = container.querySelector('.relative.h-full')
|
||||
expect(valueContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined value', () => {
|
||||
render(<InputCopy value={undefined} />)
|
||||
// Should not crash
|
||||
it('should handle undefined value', async () => {
|
||||
await renderAndFlush(<InputCopy value={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string value', () => {
|
||||
render(<InputCopy value="" />)
|
||||
it('should handle empty string value', async () => {
|
||||
await renderAndFlush(<InputCopy value="" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long values', () => {
|
||||
it('should handle very long values', async () => {
|
||||
const longValue = 'a'.repeat(500)
|
||||
render(<InputCopy value={longValue} />)
|
||||
await renderAndFlush(<InputCopy value={longValue} />)
|
||||
expect(screen.getByText(longValue)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in value', () => {
|
||||
it('should handle special characters in value', async () => {
|
||||
const specialValue = 'key-with-special-chars!@#$%^&*()'
|
||||
render(<InputCopy value={specialValue} />)
|
||||
await renderAndFlush(<InputCopy value={specialValue} />)
|
||||
expect(screen.getByText(specialValue)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -297,11 +296,10 @@ describe('InputCopy', () => {
|
||||
describe('multiple clicks', () => {
|
||||
it('should handle multiple rapid clicks', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="test" />)
|
||||
await renderAndFlush(<InputCopy value="test" />)
|
||||
|
||||
const copyableArea = screen.getByText('test')
|
||||
|
||||
// Click multiple times rapidly
|
||||
await act(async () => {
|
||||
await user.click(copyableArea)
|
||||
await user.click(copyableArea)
|
||||
@@ -1,8 +1,7 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyButton from './secret-key-button'
|
||||
import SecretKeyButton from '../secret-key-button'
|
||||
|
||||
// Mock the SecretKeyModal since it has complex dependencies
|
||||
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({ isShow, onClose, appId }: { isShow: boolean, onClose: () => void, appId?: string }) => (
|
||||
isShow
|
||||
@@ -30,7 +29,6 @@ describe('SecretKeyButton', () => {
|
||||
|
||||
it('should render the key icon', () => {
|
||||
const { container } = render(<SecretKeyButton />)
|
||||
// RiKey2Line icon should be rendered as an svg
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
@@ -58,7 +56,6 @@ describe('SecretKeyButton', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
// Open modal
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
@@ -66,7 +63,6 @@ describe('SecretKeyButton', () => {
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
@@ -81,20 +77,17 @@ describe('SecretKeyButton', () => {
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
// Open
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
|
||||
// Close
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Open again
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
@@ -205,7 +198,6 @@ describe('SecretKeyButton', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
// Initially modal should not be visible
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
@@ -213,7 +205,6 @@ describe('SecretKeyButton', () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
// Now modal should be visible
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -231,7 +222,6 @@ describe('SecretKeyButton', () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
|
||||
// Modal should be closed after clicking close
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -251,7 +241,6 @@ describe('SecretKeyButton', () => {
|
||||
button.focus()
|
||||
expect(document.activeElement).toBe(button)
|
||||
|
||||
// Press Enter to activate
|
||||
await act(async () => {
|
||||
await user.keyboard('{Enter}')
|
||||
})
|
||||
@@ -273,20 +262,17 @@ describe('SecretKeyButton', () => {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
// Click first button
|
||||
await act(async () => {
|
||||
await user.click(buttons[0])
|
||||
})
|
||||
|
||||
expect(screen.getByText('Modal for app-1')).toBeInTheDocument()
|
||||
|
||||
// Close first modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
|
||||
// Click second button
|
||||
await act(async () => {
|
||||
await user.click(buttons[1])
|
||||
})
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { CreateApiKeyResponse } from '@/models/app'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyGenerateModal from './secret-key-generate'
|
||||
import SecretKeyGenerateModal from '../secret-key-generate'
|
||||
|
||||
// Helper to create a valid CreateApiKeyResponse
|
||||
const createMockApiKey = (token: string): CreateApiKeyResponse => ({
|
||||
id: 'mock-id',
|
||||
token,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
async function renderModal(ui: React.ReactElement) {
|
||||
const result = render(ui)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
describe('SecretKeyGenerateModal', () => {
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
@@ -18,75 +25,78 @@ describe('SecretKeyGenerateModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('rendering when shown', () => {
|
||||
it('should render the modal when isShow is true', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should render the modal when isShow is true', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the generate tips text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should render the generate tips text', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the OK button', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should render the OK button', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal, so query from document.body
|
||||
it('should render the close icon', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render InputCopy component', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />)
|
||||
it('should render InputCopy component', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />)
|
||||
expect(screen.getByText('test-token-123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering when hidden', () => {
|
||||
it('should not render content when isShow is false', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} isShow={false} />)
|
||||
it('should not render content when isShow is false', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} isShow={false} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('newKey prop', () => {
|
||||
it('should display the token when newKey is provided', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />)
|
||||
it('should display the token when newKey is provided', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />)
|
||||
expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined newKey', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />)
|
||||
// Should not crash and modal should still render
|
||||
it('should handle undefined newKey', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newKey with empty token', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />)
|
||||
it('should handle newKey with empty token', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display long tokens correctly', () => {
|
||||
it('should display long tokens correctly', async () => {
|
||||
const longToken = `sk-${'a'.repeat(100)}`
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />)
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />)
|
||||
expect(screen.getByText(longToken)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close functionality', () => {
|
||||
it('should call onClose when X icon is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const onClose = vi.fn()
|
||||
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
@@ -94,81 +104,60 @@ describe('SecretKeyGenerateModal', () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
|
||||
// HeadlessUI Dialog may trigger onClose multiple times (icon click handler + dialog close)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when OK button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const onClose = vi.fn()
|
||||
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
const okButton = screen.getByRole('button', { name: /ok/i })
|
||||
await act(async () => {
|
||||
await user.click(okButton)
|
||||
})
|
||||
|
||||
// HeadlessUI Dialog calls onClose both from button click and modal close
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(
|
||||
it('should apply custom className', async () => {
|
||||
await renderModal(
|
||||
<SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />,
|
||||
)
|
||||
// Modal renders via portal
|
||||
const modal = document.body.querySelector('.custom-modal-class')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply shrink-0 class', () => {
|
||||
render(
|
||||
it('should apply shrink-0 class', async () => {
|
||||
await renderModal(
|
||||
<SecretKeyGenerateModal {...defaultProps} className="shrink-0" />,
|
||||
)
|
||||
// Modal renders via portal
|
||||
const modal = document.body.querySelector('.shrink-0')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal styling', () => {
|
||||
it('should have px-8 padding', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
it('should have px-8 padding', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const modal = document.body.querySelector('.px-8')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close icon styling', () => {
|
||||
it('should have cursor-pointer class on close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
it('should have cursor-pointer class on close icon', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct dimensions on close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg[class*="h-6"][class*="w-6"]')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have tertiary text color on close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg[class*="text-text-tertiary"]')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('header section', () => {
|
||||
it('should have flex justify-end on close container', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
it('should have flex justify-end on close container', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
const closeContainer = closeIcon?.parentElement
|
||||
expect(closeContainer).toBeInTheDocument()
|
||||
@@ -176,9 +165,8 @@ describe('SecretKeyGenerateModal', () => {
|
||||
expect(closeContainer?.className).toContain('justify-end')
|
||||
})
|
||||
|
||||
it('should have negative margin on close container', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
it('should have negative margin on close container', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
const closeContainer = closeIcon?.parentElement
|
||||
expect(closeContainer).toBeInTheDocument()
|
||||
@@ -186,9 +174,8 @@ describe('SecretKeyGenerateModal', () => {
|
||||
expect(closeContainer?.className).toContain('-mt-6')
|
||||
})
|
||||
|
||||
it('should have bottom margin on close container', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
it('should have bottom margin on close container', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
const closeContainer = closeIcon?.parentElement
|
||||
expect(closeContainer).toBeInTheDocument()
|
||||
@@ -197,46 +184,45 @@ describe('SecretKeyGenerateModal', () => {
|
||||
})
|
||||
|
||||
describe('tips text styling', () => {
|
||||
it('should have mt-1 margin on tips', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have mt-1 margin on tips', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('mt-1')
|
||||
})
|
||||
|
||||
it('should have correct font size', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have correct font size', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('text-[13px]')
|
||||
})
|
||||
|
||||
it('should have normal font weight', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have normal font weight', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('font-normal')
|
||||
})
|
||||
|
||||
it('should have leading-5 line height', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have leading-5 line height', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('leading-5')
|
||||
})
|
||||
|
||||
it('should have tertiary text color', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have tertiary text color', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('InputCopy section', () => {
|
||||
it('should render InputCopy with token value', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />)
|
||||
it('should render InputCopy with token value', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />)
|
||||
expect(screen.getByText('test-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have w-full class on InputCopy', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />)
|
||||
// The InputCopy component should have w-full
|
||||
it('should have w-full class on InputCopy', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />)
|
||||
const inputText = screen.getByText('test')
|
||||
const inputContainer = inputText.closest('.w-full')
|
||||
expect(inputContainer).toBeInTheDocument()
|
||||
@@ -244,58 +230,57 @@ describe('SecretKeyGenerateModal', () => {
|
||||
})
|
||||
|
||||
describe('OK button section', () => {
|
||||
it('should render OK button', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should render OK button', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /ok/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have button container with flex layout', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have button container with flex layout', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /ok/i })
|
||||
const container = button.parentElement
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(container?.className).toContain('flex')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on button', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have shrink-0 on button', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /ok/i })
|
||||
expect(button.className).toContain('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('button text styling', () => {
|
||||
it('should have text-xs font size on button text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have text-xs font size on button text', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const buttonText = screen.getByText('appApi.actionMsg.ok')
|
||||
expect(buttonText.className).toContain('text-xs')
|
||||
})
|
||||
|
||||
it('should have font-medium on button text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have font-medium on button text', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const buttonText = screen.getByText('appApi.actionMsg.ok')
|
||||
expect(buttonText.className).toContain('font-medium')
|
||||
})
|
||||
|
||||
it('should have secondary text color on button text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should have secondary text color on button text', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const buttonText = screen.getByText('appApi.actionMsg.ok')
|
||||
expect(buttonText.className).toContain('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default prop values', () => {
|
||||
it('should default isShow to false', () => {
|
||||
// When isShow is explicitly set to false
|
||||
render(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />)
|
||||
it('should default isShow to false', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal title', () => {
|
||||
it('should display the correct title', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
it('should display the correct title', async () => {
|
||||
await renderModal(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,25 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyModal from './secret-key-modal'
|
||||
import { afterEach } from 'vitest'
|
||||
import SecretKeyModal from '../secret-key-modal'
|
||||
|
||||
async function renderModal(ui: React.ReactElement) {
|
||||
const result = render(ui)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function flushTransitions() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// Mock the app context
|
||||
const mockCurrentWorkspace = vi.fn().mockReturnValue({
|
||||
id: 'workspace-1',
|
||||
name: 'Test Workspace',
|
||||
@@ -18,7 +35,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the timestamp hook
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`),
|
||||
@@ -26,7 +42,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API services
|
||||
const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' })
|
||||
const mockDelAppApikey = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/apps', () => ({
|
||||
@@ -41,7 +56,6 @@ vi.mock('@/service/datasets', () => ({
|
||||
delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args),
|
||||
}))
|
||||
|
||||
// Mock React Query hooks for apps
|
||||
const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false)
|
||||
const mockInvalidateAppApiKeys = vi.fn()
|
||||
@@ -54,7 +68,6 @@ vi.mock('@/service/use-apps', () => ({
|
||||
useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys,
|
||||
}))
|
||||
|
||||
// Mock React Query hooks for datasets
|
||||
const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false)
|
||||
const mockInvalidateDatasetApiKeys = vi.fn()
|
||||
@@ -75,6 +88,7 @@ describe('SecretKeyModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
@@ -84,53 +98,57 @@ describe('SecretKeyModal', () => {
|
||||
mockIsDatasetApiKeysLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('rendering when shown', () => {
|
||||
it('should render the modal when isShow is true', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
it('should render the modal when isShow is true', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the tips text', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
it('should render the tips text', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the create new key button', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
it('should render the create new key button', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Modal renders via portal, so we need to query from document.body
|
||||
it('should render the close icon', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering when hidden', () => {
|
||||
it('should not render content when isShow is false', () => {
|
||||
render(<SecretKeyModal {...defaultProps} isShow={false} />)
|
||||
it('should not render content when isShow is false', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} isShow={false} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show loading when app API keys are loading', () => {
|
||||
it('should show loading when app API keys are loading', async () => {
|
||||
mockIsAppApiKeysLoading.mockReturnValue(true)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading when dataset API keys are loading', () => {
|
||||
it('should show loading when dataset API keys are loading', async () => {
|
||||
mockIsDatasetApiKeysLoading.mockReturnValue(true)
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show loading when data is loaded', () => {
|
||||
it('should not show loading when data is loaded', async () => {
|
||||
mockIsAppApiKeysLoading.mockReturnValue(false)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -145,49 +163,43 @@ describe('SecretKeyModal', () => {
|
||||
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
|
||||
})
|
||||
|
||||
it('should render API keys when available', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
// Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789'
|
||||
it('should render API keys when available', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created time for keys', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should render created time for keys', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render last used time for keys', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should render last used time for keys', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "never" for keys without last_used_at', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should render "never" for keys without last_used_at', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('appApi.never')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button for managers', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
// Delete button contains RiDeleteBinLine SVG - look for SVGs with h-4 w-4 class within buttons
|
||||
it('should render delete button for managers', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// There should be at least 3 buttons: copy feedback, delete, and create
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
// Check for delete icon SVG - Modal renders via portal
|
||||
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
|
||||
expect(deleteIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render delete button for non-managers', () => {
|
||||
it('should not render delete button for non-managers', async () => {
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(false)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
// The specific delete action button should not be present
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const actionButtons = screen.getAllByRole('button')
|
||||
// Should only have copy and create buttons, not delete
|
||||
expect(actionButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render table headers', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should render table headers', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument()
|
||||
@@ -203,20 +215,18 @@ describe('SecretKeyModal', () => {
|
||||
mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys })
|
||||
})
|
||||
|
||||
it('should render dataset API keys when no appId', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Token 'dk-abc123def456ghi789' (21 chars) -> first 3 'dk-' + '...' + last 20 'k-abc123def456ghi789'
|
||||
it('should render dataset API keys when no appId', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close functionality', () => {
|
||||
it('should call onClose when X icon is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const onClose = vi.fn()
|
||||
render(<SecretKeyModal {...defaultProps} onClose={onClose} />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
// Modal renders via portal, so we need to query from document.body
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
@@ -224,14 +234,14 @@ describe('SecretKeyModal', () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create new key', () => {
|
||||
it('should call create API for app when button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
@@ -247,8 +257,8 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should call create API for dataset when no appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
@@ -264,8 +274,8 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should show generate modal after creating key', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
@@ -273,14 +283,13 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// The SecretKeyGenerateModal should be shown with the new token
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate app API keys after creating', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
@@ -293,8 +302,8 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should invalidate dataset API keys after creating (no appId)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
@@ -306,17 +315,17 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable create button when no workspace', () => {
|
||||
it('should disable create button when no workspace', async () => {
|
||||
mockCurrentWorkspace.mockReturnValue(null)
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
|
||||
expect(createButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable create button when not editor', () => {
|
||||
it('should disable create button when not editor', async () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
|
||||
expect(createButton).toBeDisabled()
|
||||
@@ -332,80 +341,74 @@ describe('SecretKeyModal', () => {
|
||||
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
|
||||
})
|
||||
|
||||
it('should render delete button for managers', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should render delete button for managers', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find buttons that contain SVG (delete/copy buttons)
|
||||
const actionButtons = screen.getAllByRole('button')
|
||||
// There should be at least copy, delete, and create buttons
|
||||
expect(actionButtons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should render API key row with actions', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should render API key row with actions', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Verify the truncated token is rendered
|
||||
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have action buttons in the key row', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
it('should have action buttons in the key row', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Check for action button containers - Modal renders via portal
|
||||
const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]')
|
||||
expect(actionContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have delete button visible for managers', async () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find the delete button by looking for the button with the delete icon
|
||||
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
|
||||
const deleteButton = deleteIcon?.closest('button')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show confirm dialog when delete button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find delete button by action-btn class (second action button after copy)
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
// The delete button is the second action button (first is copy)
|
||||
const deleteButton = actionButtons[1]
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Confirm dialog should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
})
|
||||
|
||||
it('should call delete API for app when confirmed', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
|
||||
// Find and click the confirm button
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -417,24 +420,25 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should invalidate app API keys after deleting', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -443,33 +447,31 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Wait for confirm dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
|
||||
// Click cancel button
|
||||
const cancelButton = screen.getByText('common.operation.cancel')
|
||||
await act(async () => {
|
||||
await user.click(cancelButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Confirm dialog should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Delete API should not be called
|
||||
expect(mockDelAppApikey).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -484,24 +486,25 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should call delete API for dataset when no appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -513,24 +516,25 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
it('should invalidate dataset API keys after deleting', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -540,46 +544,42 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
|
||||
describe('token truncation', () => {
|
||||
it('should truncate token correctly', () => {
|
||||
it('should truncate token correctly', async () => {
|
||||
const apiKeys = [
|
||||
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
|
||||
]
|
||||
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
|
||||
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Token format: first 3 chars + ... + last 20 chars
|
||||
// 'sk-abcdefghijklmnopqrstuvwxyz1234567890' -> 'sk-...qrstuvwxyz1234567890'
|
||||
expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should render modal with expected structure', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Modal should render and contain the title
|
||||
it('should render modal with expected structure', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create button with flex styling', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Modal renders via portal, so query from document.body
|
||||
it('should render create button with flex styling', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
const flexContainers = document.body.querySelectorAll('[class*="flex"]')
|
||||
expect(flexContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should not render table when no keys', () => {
|
||||
it('should not render table when no keys', async () => {
|
||||
mockAppApiKeysData.mockReturnValue({ data: [] })
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render table when data is null', () => {
|
||||
it('should not render table when data is null', async () => {
|
||||
mockAppApiKeysData.mockReturnValue(null)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -587,23 +587,23 @@ describe('SecretKeyModal', () => {
|
||||
|
||||
describe('SecretKeyGenerateModal', () => {
|
||||
it('should close generate modal on close', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Create a new key to open generate modal
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the close/OK button in generate modal
|
||||
const okButton = screen.getByText('appApi.actionMsg.ok')
|
||||
await act(async () => {
|
||||
await user.click(okButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ActionItem } from './actions/types'
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Command } from 'cmdk'
|
||||
import * as React from 'react'
|
||||
import CommandSelector from './command-selector'
|
||||
import CommandSelector from '../command-selector'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/app',
|
||||
@@ -16,7 +16,7 @@ const slashCommandsMock = [{
|
||||
isAvailable: () => true,
|
||||
}]
|
||||
|
||||
vi.mock('./actions/commands/registry', () => ({
|
||||
vi.mock('../actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
getAvailableCommands: () => slashCommandsMock,
|
||||
},
|
||||
@@ -97,7 +97,6 @@ describe('CommandSelector', () => {
|
||||
</Command>,
|
||||
)
|
||||
|
||||
// Should show the zen command from mock
|
||||
expect(screen.getByText('/zen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -125,7 +124,6 @@ describe('CommandSelector', () => {
|
||||
</Command>,
|
||||
)
|
||||
|
||||
// Should show @ commands but not /
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.queryByText('/')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from '../context'
|
||||
|
||||
let pathnameMock: string | null | undefined = '/'
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -8,7 +8,7 @@ vi.mock('next/navigation', () => ({
|
||||
}))
|
||||
|
||||
let isWorkflowPageMock = false
|
||||
vi.mock('../workflow/constants', () => ({
|
||||
vi.mock('../../workflow/constants', () => ({
|
||||
isInWorkflowPage: () => isWorkflowPageMock,
|
||||
}))
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ActionItem, SearchResult } from './actions/types'
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import GotoAnything from './index'
|
||||
import GotoAnything from '../index'
|
||||
|
||||
// Test helper type that matches SearchResult but allows ReactNode for icon and flexible data
|
||||
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
|
||||
icon?: ReactNode
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Mock react-i18next to return namespace.key format
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const ns = options?.ns || 'common'
|
||||
return `${ns}.${key}`
|
||||
},
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const routerPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
@@ -65,7 +53,7 @@ vi.mock('@/context/i18n', () => ({
|
||||
}))
|
||||
|
||||
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
vi.mock('./context', () => ({
|
||||
vi.mock('../context', () => ({
|
||||
useGotoAnythingContext: () => contextValue,
|
||||
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
@@ -93,13 +81,13 @@ const createActionsMock = vi.fn(() => actionsMock)
|
||||
const matchActionMock = vi.fn(() => undefined)
|
||||
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
|
||||
|
||||
vi.mock('./actions', () => ({
|
||||
vi.mock('../actions', () => ({
|
||||
createActions: () => createActionsMock(),
|
||||
matchAction: () => matchActionMock(),
|
||||
searchAnything: () => searchAnythingMock(),
|
||||
}))
|
||||
|
||||
vi.mock('./actions/commands', () => ({
|
||||
vi.mock('../actions/commands', () => ({
|
||||
SlashCommandProvider: () => null,
|
||||
}))
|
||||
|
||||
@@ -110,7 +98,7 @@ type MockSlashCommand = {
|
||||
} | null
|
||||
|
||||
let mockFindCommand: MockSlashCommand = null
|
||||
vi.mock('./actions/commands/registry', () => ({
|
||||
vi.mock('../actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
findCommand: () => mockFindCommand,
|
||||
getAvailableCommands: () => [],
|
||||
@@ -129,7 +117,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
selectWorkflowNode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/install-plugin/install-from-marketplace', () => ({
|
||||
vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => (
|
||||
<div data-testid="install-modal">
|
||||
<span>{props.manifest?.name}</span>
|
||||
@@ -207,23 +195,19 @@ describe('GotoAnything', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GotoAnything />)
|
||||
|
||||
// Open modal first time
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Type something
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'test')
|
||||
|
||||
// Close modal
|
||||
triggerKeyPress('esc')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open modal again - should be empty
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
@@ -278,7 +262,6 @@ describe('GotoAnything', () => {
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'test query')
|
||||
|
||||
// Should not throw and input should have value
|
||||
expect(input).toHaveValue('test query')
|
||||
})
|
||||
})
|
||||
@@ -303,7 +286,6 @@ describe('GotoAnything', () => {
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'search')
|
||||
|
||||
// Loading state shows in both EmptyState (spinner) and Footer
|
||||
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
|
||||
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { appAction } from '../app'
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirectionPath: vi.fn((_isAdmin: boolean, app: { id: string }) => `/app/${app.id}`),
|
||||
}))
|
||||
|
||||
vi.mock('../../../app/type-selector', () => ({
|
||||
AppTypeIcon: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/app-icon', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
describe('appAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(appAction.key).toBe('@app')
|
||||
expect(appAction.shortcut).toBe('@app')
|
||||
})
|
||||
|
||||
it('returns parsed app results on success', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'app-1', name: 'My App', description: 'A great app', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const results = await appAction.search('@app test', 'test', 'en')
|
||||
|
||||
expect(fetchAppList).toHaveBeenCalledWith({
|
||||
url: 'apps',
|
||||
params: { page: 1, name: 'test' },
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'app-1',
|
||||
title: 'My App',
|
||||
type: 'app',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty array when response has no data', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
|
||||
|
||||
const results = await appAction.search('@app', '', 'en')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on API failure', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
|
||||
|
||||
const results = await appAction.search('@app fail', 'fail', 'en')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
276
web/app/components/goto-anything/actions/__tests__/index.spec.ts
Normal file
276
web/app/components/goto-anything/actions/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { ActionItem, SearchResult } from '../types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { App } from '@/types/app'
|
||||
import { slashCommandRegistry } from '../commands/registry'
|
||||
import { createActions, matchAction, searchAnything } from '../index'
|
||||
|
||||
vi.mock('../app', () => ({
|
||||
appAction: {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
description: 'Search apps',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../knowledge', () => ({
|
||||
knowledgeAction: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'Knowledge',
|
||||
description: 'Search knowledge',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../plugin', () => ({
|
||||
pluginAction: {
|
||||
key: '@plugin',
|
||||
shortcut: '@plugin',
|
||||
title: 'Plugins',
|
||||
description: 'Search plugins',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../commands', () => ({
|
||||
slashAction: {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: 'Commands',
|
||||
description: 'Slash commands',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../workflow-nodes', () => ({
|
||||
workflowNodesAction: {
|
||||
key: '@node',
|
||||
shortcut: '@node',
|
||||
title: 'Workflow Nodes',
|
||||
description: 'Search workflow nodes',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../rag-pipeline-nodes', () => ({
|
||||
ragPipelineNodesAction: {
|
||||
key: '@node',
|
||||
shortcut: '@node',
|
||||
title: 'RAG Pipeline Nodes',
|
||||
description: 'Search RAG nodes',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../commands/registry')
|
||||
|
||||
describe('createActions', () => {
|
||||
it('returns base actions when neither workflow nor rag-pipeline page', () => {
|
||||
const actions = createActions(false, false)
|
||||
|
||||
expect(actions).toHaveProperty('slash')
|
||||
expect(actions).toHaveProperty('app')
|
||||
expect(actions).toHaveProperty('knowledge')
|
||||
expect(actions).toHaveProperty('plugin')
|
||||
expect(actions).not.toHaveProperty('node')
|
||||
})
|
||||
|
||||
it('includes workflow nodes action on workflow pages', () => {
|
||||
const actions = createActions(true, false) as Record<string, ActionItem>
|
||||
|
||||
expect(actions).toHaveProperty('node')
|
||||
expect(actions.node.title).toBe('Workflow Nodes')
|
||||
})
|
||||
|
||||
it('includes rag-pipeline nodes action on rag-pipeline pages', () => {
|
||||
const actions = createActions(false, true) as Record<string, ActionItem>
|
||||
|
||||
expect(actions).toHaveProperty('node')
|
||||
expect(actions.node.title).toBe('RAG Pipeline Nodes')
|
||||
})
|
||||
|
||||
it('rag-pipeline page takes priority over workflow page', () => {
|
||||
const actions = createActions(true, true) as Record<string, ActionItem>
|
||||
|
||||
expect(actions.node.title).toBe('RAG Pipeline Nodes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchAnything', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('delegates to specific action when actionItem is provided', async () => {
|
||||
const mockResults: SearchResult[] = [
|
||||
{ id: '1', title: 'App1', type: 'app', data: {} as unknown as App },
|
||||
]
|
||||
const action: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
description: 'Search apps',
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', '@app myquery', action)
|
||||
|
||||
expect(action.search).toHaveBeenCalledWith('@app myquery', 'myquery', 'en')
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('strips action prefix from search term', async () => {
|
||||
const action: ActionItem = {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'KB',
|
||||
description: 'Search KB',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
|
||||
await searchAnything('en', '@kb hello', action)
|
||||
|
||||
expect(action.search).toHaveBeenCalledWith('@kb hello', 'hello', 'en')
|
||||
})
|
||||
|
||||
it('returns empty for queries starting with @ without actionItem', async () => {
|
||||
const results = await searchAnything('en', '@unknown')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for queries starting with / without actionItem', async () => {
|
||||
const results = await searchAnything('en', '/theme')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('handles action search failure gracefully', async () => {
|
||||
const action: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
description: 'Search apps',
|
||||
search: vi.fn().mockRejectedValue(new Error('network error')),
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', '@app test', action)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('runs global search across all non-slash actions for plain queries', async () => {
|
||||
const appResults: SearchResult[] = [
|
||||
{ id: 'a1', title: 'My App', type: 'app', data: {} as unknown as App },
|
||||
]
|
||||
const kbResults: SearchResult[] = [
|
||||
{ id: 'k1', title: 'My KB', type: 'knowledge', data: {} as unknown as DataSet },
|
||||
]
|
||||
|
||||
const dynamicActions: Record<string, ActionItem> = {
|
||||
slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn().mockResolvedValue([]) },
|
||||
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockResolvedValue(appResults) },
|
||||
knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn().mockResolvedValue(kbResults) },
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', 'my query', undefined, dynamicActions)
|
||||
|
||||
expect(dynamicActions.slash.search).not.toHaveBeenCalled()
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'a1' }),
|
||||
expect.objectContaining({ id: 'k1' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('handles partial search failures in global search gracefully', async () => {
|
||||
const dynamicActions: Record<string, ActionItem> = {
|
||||
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) },
|
||||
knowledge: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'KB',
|
||||
description: '',
|
||||
search: vi.fn().mockResolvedValue([
|
||||
{ id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet },
|
||||
]),
|
||||
},
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', 'query', undefined, dynamicActions)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('k1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchAction', () => {
|
||||
const actions: Record<string, ActionItem> = {
|
||||
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() },
|
||||
knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn() },
|
||||
plugin: { key: '@plugin', shortcut: '@plugin', title: 'Plugin', description: '', search: vi.fn() },
|
||||
slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('matches @app query', () => {
|
||||
const result = matchAction('@app test', actions)
|
||||
expect(result?.key).toBe('@app')
|
||||
})
|
||||
|
||||
it('matches @kb shortcut', () => {
|
||||
const result = matchAction('@kb test', actions)
|
||||
expect(result?.key).toBe('@knowledge')
|
||||
})
|
||||
|
||||
it('matches @plugin query', () => {
|
||||
const result = matchAction('@plugin test', actions)
|
||||
expect(result?.key).toBe('@plugin')
|
||||
})
|
||||
|
||||
it('returns undefined for unmatched query', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([])
|
||||
const result = matchAction('random query', actions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('slash command matching', () => {
|
||||
it('matches submenu command with full name', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/theme', actions)
|
||||
expect(result?.key).toBe('/')
|
||||
})
|
||||
|
||||
it('matches submenu command with args', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/theme dark', actions)
|
||||
expect(result?.key).toBe('/')
|
||||
})
|
||||
|
||||
it('does not match direct-mode commands', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'docs', mode: 'direct', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/docs', actions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match partial slash command name', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/the', actions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { knowledgeAction } from '../knowledge'
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
fetchDatasets: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: string[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/icons/src/vender/solid/files', () => ({
|
||||
Folder: () => null,
|
||||
}))
|
||||
|
||||
describe('knowledgeAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(knowledgeAction.key).toBe('@knowledge')
|
||||
expect(knowledgeAction.shortcut).toBe('@kb')
|
||||
})
|
||||
|
||||
it('returns parsed dataset results on success', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'ds-1', name: 'My Knowledge', description: 'A KB', provider: 'vendor', embedding_available: true } as unknown as DataSet,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge query', 'query', 'en')
|
||||
|
||||
expect(fetchDatasets).toHaveBeenCalledWith({
|
||||
url: '/datasets',
|
||||
params: { page: 1, limit: 10, keyword: 'query' },
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'ds-1',
|
||||
title: 'My Knowledge',
|
||||
type: 'knowledge',
|
||||
})
|
||||
})
|
||||
|
||||
it('generates correct path for external provider', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'ds-ext', name: 'External', description: '', provider: 'external', embedding_available: true } as unknown as DataSet,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge', '', 'en')
|
||||
|
||||
expect(results[0].path).toBe('/datasets/ds-ext/hitTesting')
|
||||
})
|
||||
|
||||
it('generates correct path for non-external provider', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'ds-2', name: 'Internal', description: '', provider: 'vendor', embedding_available: true } as unknown as DataSet,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge', '', 'en')
|
||||
|
||||
expect(results[0].path).toBe('/datasets/ds-2/documents')
|
||||
})
|
||||
|
||||
it('returns empty array on API failure', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { pluginAction } from '../plugin'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
postMarketplace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: vi.fn((obj: Record<string, string> | string, locale: string) => {
|
||||
if (typeof obj === 'string')
|
||||
return obj
|
||||
return obj[locale] || obj.en_US || ''
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../plugins/card/base/card-icon', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../plugins/marketplace/utils', () => ({
|
||||
getPluginIconInMarketplace: vi.fn(() => 'icon-url'),
|
||||
}))
|
||||
|
||||
describe('pluginAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(pluginAction.key).toBe('@plugin')
|
||||
expect(pluginAction.shortcut).toBe('@plugin')
|
||||
})
|
||||
|
||||
it('returns parsed plugin results on success', async () => {
|
||||
const { postMarketplace } = await import('@/service/base')
|
||||
vi.mocked(postMarketplace).mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{ name: 'plugin-1', label: { en_US: 'My Plugin' }, brief: { en_US: 'A plugin' }, icon: 'icon.png' },
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const results = await pluginAction.search('@plugin', 'test', 'en_US')
|
||||
|
||||
expect(postMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
|
||||
body: { page: 1, page_size: 10, query: 'test', type: 'plugin' },
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'plugin-1',
|
||||
title: 'My Plugin',
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty array when response has unexpected structure', async () => {
|
||||
const { postMarketplace } = await import('@/service/base')
|
||||
vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
|
||||
|
||||
const results = await pluginAction.search('@plugin', 'test', 'en')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on API failure', async () => {
|
||||
const { postMarketplace } = await import('@/service/base')
|
||||
vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
|
||||
|
||||
const results = await pluginAction.search('@plugin', 'fail', 'en')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { executeCommand, registerCommands, unregisterCommands } from '../command-bus'
|
||||
|
||||
describe('command-bus', () => {
|
||||
afterEach(() => {
|
||||
unregisterCommands(['test.a', 'test.b', 'test.c', 'async.cmd', 'noop'])
|
||||
})
|
||||
|
||||
describe('registerCommands / executeCommand', () => {
|
||||
it('registers and executes a sync command', async () => {
|
||||
const handler = vi.fn()
|
||||
registerCommands({ 'test.a': handler })
|
||||
|
||||
await executeCommand('test.a', { value: 42 })
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ value: 42 })
|
||||
})
|
||||
|
||||
it('registers and executes an async command', async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined)
|
||||
registerCommands({ 'async.cmd': handler })
|
||||
|
||||
await executeCommand('async.cmd')
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers multiple commands at once', async () => {
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
registerCommands({ 'test.a': handlerA, 'test.b': handlerB })
|
||||
|
||||
await executeCommand('test.a')
|
||||
await executeCommand('test.b')
|
||||
|
||||
expect(handlerA).toHaveBeenCalled()
|
||||
expect(handlerB).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently ignores unregistered command names', async () => {
|
||||
await expect(executeCommand('nonexistent')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes undefined args when not provided', async () => {
|
||||
const handler = vi.fn()
|
||||
registerCommands({ 'test.c': handler })
|
||||
|
||||
await executeCommand('test.c')
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregisterCommands', () => {
|
||||
it('removes commands so they can no longer execute', async () => {
|
||||
const handler = vi.fn()
|
||||
registerCommands({ 'test.a': handler })
|
||||
|
||||
unregisterCommands(['test.a'])
|
||||
await executeCommand('test.a')
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles unregistering non-existent commands gracefully', () => {
|
||||
expect(() => unregisterCommands(['nope'])).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tests for direct-mode commands that share similar patterns:
|
||||
* docs, account, community, forum
|
||||
*
|
||||
* Each command: opens a URL or navigates, has direct mode, and registers a navigation command.
|
||||
*/
|
||||
import { accountCommand } from '../account'
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { communityCommand } from '../community'
|
||||
import { docsCommand } from '../docs'
|
||||
import { forumCommand } from '../forum'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
language: 'en',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getDocLanguage: (locale: string) => locale === 'en' ? 'en' : locale,
|
||||
}))
|
||||
|
||||
describe('docsCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(docsCommand.name).toBe('docs')
|
||||
expect(docsCommand.mode).toBe('direct')
|
||||
expect(docsCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute opens documentation in new tab', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
docsCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://docs.dify.ai'),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('search returns a single doc result', async () => {
|
||||
const results = await docsCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'doc',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.doc', args: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('registers navigation.doc command', () => {
|
||||
docsCommand.register?.({} as Record<string, never>)
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.doc command', () => {
|
||||
docsCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountCommand', () => {
|
||||
let originalHref: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalHref = window.location.href
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(accountCommand.name).toBe('account')
|
||||
expect(accountCommand.mode).toBe('direct')
|
||||
expect(accountCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute navigates to /account', () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
|
||||
accountCommand.execute?.()
|
||||
expect(window.location.href).toBe('/account')
|
||||
})
|
||||
|
||||
it('search returns account result', async () => {
|
||||
const results = await accountCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'account',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.account', args: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('registers navigation.account command', () => {
|
||||
accountCommand.register?.({} as Record<string, never>)
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.account': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.account command', () => {
|
||||
accountCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.account'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('communityCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(communityCommand.name).toBe('community')
|
||||
expect(communityCommand.mode).toBe('direct')
|
||||
expect(communityCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute opens Discord URL', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
communityCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://discord.gg/5AEfbxcd9k',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('search returns community result', async () => {
|
||||
const results = await communityCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'community',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.community' },
|
||||
})
|
||||
})
|
||||
|
||||
it('registers navigation.community command', () => {
|
||||
communityCommand.register?.({} as Record<string, never>)
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.community command', () => {
|
||||
communityCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('forumCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(forumCommand.name).toBe('forum')
|
||||
expect(forumCommand.mode).toBe('direct')
|
||||
expect(forumCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute opens forum URL', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
forumCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://forum.dify.ai',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('search returns forum result', async () => {
|
||||
const results = await forumCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'forum',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.forum' },
|
||||
})
|
||||
})
|
||||
|
||||
it('registers navigation.forum command', () => {
|
||||
forumCommand.register?.({} as Record<string, never>)
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.forum command', () => {
|
||||
forumCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { languageCommand } from '../language'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
languages: [
|
||||
{ value: 'en-US', name: 'English', supported: true },
|
||||
{ value: 'zh-Hans', name: '简体中文', supported: true },
|
||||
{ value: 'ja-JP', name: '日本語', supported: true },
|
||||
{ value: 'unsupported', name: 'Unsupported', supported: false },
|
||||
],
|
||||
}))
|
||||
|
||||
describe('languageCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(languageCommand.name).toBe('language')
|
||||
expect(languageCommand.aliases).toEqual(['lang'])
|
||||
expect(languageCommand.mode).toBe('submenu')
|
||||
expect(languageCommand.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns all supported languages when query is empty', async () => {
|
||||
const results = await languageCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(3) // 3 supported languages
|
||||
expect(results.every(r => r.type === 'command')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters languages by name query', async () => {
|
||||
const results = await languageCommand.search('english', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('lang-en-US')
|
||||
})
|
||||
|
||||
it('filters languages by value query', async () => {
|
||||
const results = await languageCommand.search('zh', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('lang-zh-Hans')
|
||||
})
|
||||
|
||||
it('returns command data with i18n.set command', async () => {
|
||||
const results = await languageCommand.search('', 'en')
|
||||
|
||||
results.forEach((r) => {
|
||||
expect(r.data.command).toBe('i18n.set')
|
||||
expect(r.data.args).toHaveProperty('locale')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers i18n.set command', () => {
|
||||
languageCommand.register?.({ setLocale: vi.fn() })
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'i18n.set': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters i18n.set command', () => {
|
||||
languageCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['i18n.set'])
|
||||
})
|
||||
|
||||
it('registered handler calls setLocale with correct locale', async () => {
|
||||
const setLocale = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(registerCommands).mockImplementation((map) => {
|
||||
map['i18n.set']?.({ locale: 'zh-Hans' })
|
||||
})
|
||||
|
||||
languageCommand.register?.({ setLocale })
|
||||
|
||||
expect(setLocale).toHaveBeenCalledWith('zh-Hans')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { SlashCommandHandler } from '../types'
|
||||
import { SlashCommandRegistry } from '../registry'
|
||||
|
||||
function createHandler(overrides: Partial<SlashCommandHandler> = {}): SlashCommandHandler {
|
||||
return {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SlashCommandRegistry', () => {
|
||||
let registry: SlashCommandRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new SlashCommandRegistry()
|
||||
})
|
||||
|
||||
describe('register & findCommand', () => {
|
||||
it('registers a handler and retrieves it by name', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
|
||||
expect(registry.findCommand('docs')).toBe(handler)
|
||||
})
|
||||
|
||||
it('registers aliases so handler is found by any alias', () => {
|
||||
const handler = createHandler({ name: 'language', aliases: ['lang', 'l'] })
|
||||
registry.register(handler)
|
||||
|
||||
expect(registry.findCommand('language')).toBe(handler)
|
||||
expect(registry.findCommand('lang')).toBe(handler)
|
||||
expect(registry.findCommand('l')).toBe(handler)
|
||||
})
|
||||
|
||||
it('calls handler.register with provided deps', () => {
|
||||
const handler = createHandler({ name: 'theme' })
|
||||
const deps = { setTheme: vi.fn() }
|
||||
registry.register(handler, deps)
|
||||
|
||||
expect(handler.register).toHaveBeenCalledWith(deps)
|
||||
})
|
||||
|
||||
it('does not call handler.register when no deps provided', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
|
||||
expect(handler.register).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns undefined for unknown command name', () => {
|
||||
expect(registry.findCommand('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregister', () => {
|
||||
it('removes handler by name', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
registry.unregister('docs')
|
||||
|
||||
expect(registry.findCommand('docs')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes all aliases', () => {
|
||||
const handler = createHandler({ name: 'language', aliases: ['lang'] })
|
||||
registry.register(handler)
|
||||
registry.unregister('language')
|
||||
|
||||
expect(registry.findCommand('language')).toBeUndefined()
|
||||
expect(registry.findCommand('lang')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls handler.unregister', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
registry.unregister('docs')
|
||||
|
||||
expect(handler.unregister).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op for unknown command', () => {
|
||||
expect(() => registry.unregister('unknown')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllCommands', () => {
|
||||
it('returns deduplicated handlers', () => {
|
||||
const h1 = createHandler({ name: 'theme', aliases: ['t'] })
|
||||
const h2 = createHandler({ name: 'docs' })
|
||||
registry.register(h1)
|
||||
registry.register(h2)
|
||||
|
||||
const commands = registry.getAllCommands()
|
||||
expect(commands).toHaveLength(2)
|
||||
expect(commands).toContainEqual(expect.objectContaining({ name: 'theme' }))
|
||||
expect(commands).toContainEqual(expect.objectContaining({ name: 'docs' }))
|
||||
})
|
||||
|
||||
it('returns empty array when nothing registered', () => {
|
||||
expect(registry.getAllCommands()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvailableCommands', () => {
|
||||
it('includes commands without isAvailable guard', () => {
|
||||
registry.register(createHandler({ name: 'docs' }))
|
||||
|
||||
expect(registry.getAvailableCommands()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes commands where isAvailable returns true', () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => true }))
|
||||
|
||||
expect(registry.getAvailableCommands()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes commands where isAvailable returns false', () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
|
||||
|
||||
expect(registry.getAvailableCommands()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns root commands for "/"', async () => {
|
||||
registry.register(createHandler({ name: 'theme', description: 'Change theme' }))
|
||||
registry.register(createHandler({ name: 'docs', description: 'Open docs' }))
|
||||
|
||||
const results = await registry.search('/')
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: expect.stringContaining('root-'),
|
||||
type: 'command',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns root commands for "/ "', async () => {
|
||||
registry.register(createHandler({ name: 'theme' }))
|
||||
|
||||
const results = await registry.search('/ ')
|
||||
expect(results).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delegates to exact-match handler for "/theme dark"', async () => {
|
||||
const mockResults = [{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} }]
|
||||
const handler = createHandler({
|
||||
name: 'theme',
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/theme dark')
|
||||
|
||||
expect(handler.search).toHaveBeenCalledWith('dark', 'en')
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('delegates to exact-match handler for command without args', async () => {
|
||||
const handler = createHandler({ name: 'docs', search: vi.fn().mockResolvedValue([]) })
|
||||
registry.register(handler)
|
||||
|
||||
await registry.search('/docs')
|
||||
|
||||
expect(handler.search).toHaveBeenCalledWith('', 'en')
|
||||
})
|
||||
|
||||
it('uses partial match when no exact match found', async () => {
|
||||
const mockResults = [{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} }]
|
||||
const handler = createHandler({
|
||||
name: 'theme',
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/the')
|
||||
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('uses alias partial match', async () => {
|
||||
const mockResults = [{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} }]
|
||||
const handler = createHandler({
|
||||
name: 'language',
|
||||
aliases: ['lang'],
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/lan')
|
||||
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('falls back to fuzzy search when nothing matches', async () => {
|
||||
registry.register(createHandler({ name: 'theme', description: 'Set theme' }))
|
||||
|
||||
const results = await registry.search('/hem')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].title).toBe('/theme')
|
||||
})
|
||||
|
||||
it('fuzzy search also matches aliases', async () => {
|
||||
registry.register(createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' }))
|
||||
|
||||
const handler = registry.findCommand('language')
|
||||
await registry.search('/lan')
|
||||
expect(handler?.search).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty when handler.search throws', async () => {
|
||||
const handler = createHandler({
|
||||
name: 'broken',
|
||||
search: vi.fn().mockRejectedValue(new Error('fail')),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/broken')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes unavailable commands from root listing', async () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
|
||||
registry.register(createHandler({ name: 'docs' }))
|
||||
|
||||
const results = await registry.search('/')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].title).toBe('/docs')
|
||||
})
|
||||
|
||||
it('skips unavailable handler in exact match', async () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
|
||||
|
||||
const results = await registry.search('/zen')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('passes locale to handler search', async () => {
|
||||
const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue([]) })
|
||||
registry.register(handler)
|
||||
|
||||
await registry.search('/theme light', 'zh')
|
||||
|
||||
expect(handler.search).toHaveBeenCalledWith('light', 'zh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCommandDependencies', () => {
|
||||
it('returns stored deps', () => {
|
||||
const deps = { setTheme: vi.fn() }
|
||||
registry.register(createHandler({ name: 'theme' }), deps)
|
||||
|
||||
expect(registry.getCommandDependencies('theme')).toBe(deps)
|
||||
})
|
||||
|
||||
it('returns undefined when no deps stored', () => {
|
||||
registry.register(createHandler({ name: 'docs' }))
|
||||
|
||||
expect(registry.getCommandDependencies('docs')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { themeCommand } from '../theme'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('themeCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(themeCommand.name).toBe('theme')
|
||||
expect(themeCommand.mode).toBe('submenu')
|
||||
expect(themeCommand.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns all theme options when query is empty', async () => {
|
||||
const results = await themeCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results.map(r => r.id)).toEqual(['system', 'light', 'dark'])
|
||||
})
|
||||
|
||||
it('returns all theme options with correct type', async () => {
|
||||
const results = await themeCommand.search('', 'en')
|
||||
|
||||
results.forEach((r) => {
|
||||
expect(r.type).toBe('command')
|
||||
expect(r.data).toEqual({ command: 'theme.set', args: expect.objectContaining({ value: expect.any(String) }) })
|
||||
})
|
||||
})
|
||||
|
||||
it('filters results by query matching id', async () => {
|
||||
const results = await themeCommand.search('dark', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers theme.set command with deps', () => {
|
||||
const deps = { setTheme: vi.fn() }
|
||||
themeCommand.register?.(deps)
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'theme.set': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters theme.set command', () => {
|
||||
themeCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['theme.set'])
|
||||
})
|
||||
|
||||
it('registered handler calls setTheme', async () => {
|
||||
const setTheme = vi.fn()
|
||||
vi.mocked(registerCommands).mockImplementation((map) => {
|
||||
map['theme.set']?.({ value: 'dark' })
|
||||
})
|
||||
|
||||
themeCommand.register?.({ setTheme })
|
||||
|
||||
expect(setTheme).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
isInWorkflowPage: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
describe('zenCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(zenCommand.name).toBe('zen')
|
||||
expect(zenCommand.mode).toBe('direct')
|
||||
expect(zenCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('exports ZEN_TOGGLE_EVENT constant', () => {
|
||||
expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
|
||||
})
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('delegates to isInWorkflowPage', async () => {
|
||||
const { isInWorkflowPage } = vi.mocked(
|
||||
await import('@/app/components/workflow/constants'),
|
||||
)
|
||||
|
||||
isInWorkflowPage.mockReturnValue(true)
|
||||
expect(zenCommand.isAvailable?.()).toBe(true)
|
||||
|
||||
isInWorkflowPage.mockReturnValue(false)
|
||||
expect(zenCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('dispatches custom zen-toggle event', () => {
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
|
||||
zenCommand.execute?.()
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
|
||||
)
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns single zen mode result', async () => {
|
||||
const results = await zenCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'zen',
|
||||
type: 'command',
|
||||
data: { command: 'workflow.zen', args: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers workflow.zen command', () => {
|
||||
zenCommand.register?.({} as Record<string, never>)
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'workflow.zen': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters workflow.zen command', () => {
|
||||
zenCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['workflow.zen'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyState from './empty-state'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, shortcuts?: string }) => {
|
||||
if (options?.shortcuts !== undefined)
|
||||
return `${key}:${options.shortcuts}`
|
||||
return `${options?.ns || 'common'}.${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import EmptyState from '../empty-state'
|
||||
|
||||
describe('EmptyState', () => {
|
||||
describe('loading variant', () => {
|
||||
@@ -86,10 +76,10 @@ describe('EmptyState', () => {
|
||||
const Actions = {
|
||||
app: { key: '@app', shortcut: '@app' },
|
||||
plugin: { key: '@plugin', shortcut: '@plugin' },
|
||||
} as unknown as Record<string, import('../actions/types').ActionItem>
|
||||
} as unknown as Record<string, import('../../actions/types').ActionItem>
|
||||
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
|
||||
|
||||
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":"@app, @plugin"}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,8 +140,7 @@ describe('EmptyState', () => {
|
||||
it('should use empty object as default Actions', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="general" />)
|
||||
|
||||
// Should show empty shortcuts
|
||||
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":""}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => {
|
||||
if (options?.count !== undefined)
|
||||
return `${key}:${options.count}`
|
||||
if (options?.scope)
|
||||
return `${key}:${options.scope}`
|
||||
return `${options?.ns || 'common'}.${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import Footer from '../footer'
|
||||
|
||||
describe('Footer', () => {
|
||||
describe('left content', () => {
|
||||
@@ -27,7 +15,7 @@ describe('Footer', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gotoAnything.resultCount:5')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show scope when not in general mode', () => {
|
||||
@@ -41,7 +29,7 @@ describe('Footer', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gotoAnything.inScope:app')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show scope when in general mode', () => {
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { SearchResult } from '../../actions/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Command } from 'cmdk'
|
||||
import ResultItem from '../result-item'
|
||||
|
||||
function renderInCommandRoot(ui: React.ReactElement) {
|
||||
return render(<Command>{ui}</Command>)
|
||||
}
|
||||
|
||||
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
|
||||
return {
|
||||
id: 'test-1',
|
||||
title: 'Test Result',
|
||||
type: 'app',
|
||||
data: {},
|
||||
...overrides,
|
||||
} as SearchResult
|
||||
}
|
||||
|
||||
describe('ResultItem', () => {
|
||||
it('renders title', () => {
|
||||
renderInCommandRoot(
|
||||
<ResultItem result={createResult({ title: 'My App' })} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description when provided', () => {
|
||||
renderInCommandRoot(
|
||||
<ResultItem
|
||||
result={createResult({ description: 'A great app' })}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('A great app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render description when absent', () => {
|
||||
const result = createResult()
|
||||
delete (result as Record<string, unknown>).description
|
||||
|
||||
renderInCommandRoot(
|
||||
<ResultItem result={result} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Result')).toBeInTheDocument()
|
||||
expect(screen.getByText('app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders result type label', () => {
|
||||
renderInCommandRoot(
|
||||
<ResultItem result={createResult({ type: 'plugin' })} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const icon = <span data-testid="custom-icon">icon</span>
|
||||
renderInCommandRoot(
|
||||
<ResultItem result={createResult({ icon })} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
renderInCommandRoot(
|
||||
<ResultItem result={createResult()} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Test Result'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SearchResult } from '../../actions/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Command } from 'cmdk'
|
||||
import ResultList from '../result-list'
|
||||
|
||||
function renderInCommandRoot(ui: React.ReactElement) {
|
||||
return render(<Command>{ui}</Command>)
|
||||
}
|
||||
|
||||
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
|
||||
return {
|
||||
id: 'test-1',
|
||||
title: 'Result 1',
|
||||
type: 'app',
|
||||
data: {},
|
||||
...overrides,
|
||||
} as SearchResult
|
||||
}
|
||||
|
||||
describe('ResultList', () => {
|
||||
it('renders grouped results with headings', () => {
|
||||
const grouped: Record<string, SearchResult[]> = {
|
||||
app: [createResult({ id: 'a1', title: 'App One', type: 'app' })],
|
||||
plugin: [createResult({ id: 'p1', title: 'Plugin One', type: 'plugin' })],
|
||||
}
|
||||
|
||||
renderInCommandRoot(
|
||||
<ResultList groupedResults={grouped} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('App One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugin One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple results in the same group', () => {
|
||||
const grouped: Record<string, SearchResult[]> = {
|
||||
app: [
|
||||
createResult({ id: 'a1', title: 'App One', type: 'app' }),
|
||||
createResult({ id: 'a2', title: 'App Two', type: 'app' }),
|
||||
],
|
||||
}
|
||||
|
||||
renderInCommandRoot(
|
||||
<ResultList groupedResults={grouped} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('App One')).toBeInTheDocument()
|
||||
expect(screen.getByText('App Two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with the correct result when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const result = createResult({ id: 'a1', title: 'Click Me', type: 'app' })
|
||||
|
||||
renderInCommandRoot(
|
||||
<ResultList groupedResults={{ app: [result] }} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Click Me'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(result)
|
||||
})
|
||||
|
||||
it('renders empty when no grouped results provided', () => {
|
||||
const { container } = renderInCommandRoot(
|
||||
<ResultList groupedResults={{}} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
const groups = container.querySelectorAll('[cmdk-group]')
|
||||
expect(groups).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses i18n keys for known group types', () => {
|
||||
const grouped: Record<string, SearchResult[]> = {
|
||||
command: [createResult({ id: 'c1', title: 'Cmd', type: 'command' })],
|
||||
}
|
||||
|
||||
renderInCommandRoot(
|
||||
<ResultList groupedResults={grouped} onSelect={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SearchInput from './search-input'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => `${options?.ns || 'common'}.${key}`,
|
||||
}),
|
||||
}))
|
||||
import SearchInput from '../search-input'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiSearchLine: ({ className }: { className?: string }) => (
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingModal } from './use-goto-anything-modal'
|
||||
import { useGotoAnythingModal } from '../use-goto-anything-modal'
|
||||
|
||||
type KeyPressEvent = {
|
||||
preventDefault: () => void
|
||||
@@ -94,20 +94,17 @@ describe('useGotoAnythingModal', () => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
|
||||
// Should remain closed because focus is in input area
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should close modal when escape is pressed and modal is open', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// Open modal first
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
// Press escape
|
||||
act(() => {
|
||||
keyPressHandlers.esc?.({ preventDefault: vi.fn() })
|
||||
})
|
||||
@@ -125,7 +122,6 @@ describe('useGotoAnythingModal', () => {
|
||||
keyPressHandlers.esc?.({ preventDefault: preventDefaultMock })
|
||||
})
|
||||
|
||||
// Should remain closed, and preventDefault should not be called
|
||||
expect(result.current.show).toBe(false)
|
||||
expect(preventDefaultMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -146,13 +142,11 @@ describe('useGotoAnythingModal', () => {
|
||||
it('should close modal when handleClose is called', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// Open modal first
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
// Close via handleClose
|
||||
act(() => {
|
||||
result.current.handleClose()
|
||||
})
|
||||
@@ -219,14 +213,12 @@ describe('useGotoAnythingModal', () => {
|
||||
it('should not call requestAnimationFrame when modal closes', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// First open
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
// Then close
|
||||
act(() => {
|
||||
result.current.setShow(false)
|
||||
})
|
||||
@@ -236,7 +228,6 @@ describe('useGotoAnythingModal', () => {
|
||||
})
|
||||
|
||||
it('should focus input when modal opens and inputRef.current exists', () => {
|
||||
// Mock requestAnimationFrame to execute callback immediately
|
||||
const originalRAF = window.requestAnimationFrame
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
@@ -245,11 +236,9 @@ describe('useGotoAnythingModal', () => {
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// Create a mock input element with focus method
|
||||
const mockFocus = vi.fn()
|
||||
const mockInput = { focus: mockFocus } as unknown as HTMLInputElement
|
||||
|
||||
// Manually set the inputRef
|
||||
Object.defineProperty(result.current.inputRef, 'current', {
|
||||
value: mockInput,
|
||||
writable: true,
|
||||
@@ -261,12 +250,10 @@ describe('useGotoAnythingModal', () => {
|
||||
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
|
||||
// Restore original requestAnimationFrame
|
||||
window.requestAnimationFrame = originalRAF
|
||||
})
|
||||
|
||||
it('should not throw when inputRef.current is null when modal opens', () => {
|
||||
// Mock requestAnimationFrame to execute callback immediately
|
||||
const originalRAF = window.requestAnimationFrame
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
@@ -275,16 +262,12 @@ describe('useGotoAnythingModal', () => {
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// inputRef.current is already null by default
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
// Restore original requestAnimationFrame
|
||||
window.requestAnimationFrame = originalRAF
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import type * as React from 'react'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { CommonNodeType } from '../../workflow/types'
|
||||
import type { Plugin } from '../../../plugins/types'
|
||||
import type { CommonNodeType } from '../../../workflow/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { App } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingNavigation } from './use-goto-anything-navigation'
|
||||
import { useGotoAnythingNavigation } from '../use-goto-anything-navigation'
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockSelectWorkflowNode = vi.fn()
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../actions/commands/registry', () => ({
|
||||
vi.mock('../../actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
findCommand: () => mockFindCommandResult,
|
||||
},
|
||||
@@ -117,7 +117,6 @@ describe('useGotoAnythingNavigation', () => {
|
||||
})
|
||||
|
||||
expect(options.onClose).not.toHaveBeenCalled()
|
||||
// Should proceed with submenu mode
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ')
|
||||
})
|
||||
|
||||
@@ -177,7 +176,6 @@ describe('useGotoAnythingNavigation', () => {
|
||||
result.current.handleCommandSelect('/unknown')
|
||||
})
|
||||
|
||||
// Should proceed with submenu mode
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ')
|
||||
})
|
||||
})
|
||||
@@ -333,13 +331,11 @@ describe('useGotoAnythingNavigation', () => {
|
||||
it('should clear activePlugin when set to undefined', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
|
||||
// First set a plugin
|
||||
act(() => {
|
||||
result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin)
|
||||
})
|
||||
expect(result.current.activePlugin).toBeDefined()
|
||||
|
||||
// Then clear it
|
||||
act(() => {
|
||||
result.current.setActivePlugin(undefined)
|
||||
})
|
||||
@@ -356,7 +352,6 @@ describe('useGotoAnythingNavigation', () => {
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('@app')
|
||||
})
|
||||
@@ -364,8 +359,6 @@ describe('useGotoAnythingNavigation', () => {
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// No error should occur
|
||||
})
|
||||
|
||||
it('should handle missing slash action', () => {
|
||||
@@ -375,7 +368,6 @@ describe('useGotoAnythingNavigation', () => {
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: 'cmd-1',
|
||||
@@ -384,8 +376,6 @@ describe('useGotoAnythingNavigation', () => {
|
||||
data: { command: 'test-command' },
|
||||
})
|
||||
})
|
||||
|
||||
// No error should occur
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SearchResult } from '../actions/types'
|
||||
import type { SearchResult } from '../../actions/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingResults } from './use-goto-anything-results'
|
||||
import { useGotoAnythingResults } from '../use-goto-anything-results'
|
||||
|
||||
type MockQueryResult = {
|
||||
data: Array<{ id: string, type: string, title: string }> | undefined
|
||||
@@ -30,7 +30,7 @@ vi.mock('@/context/i18n', () => ({
|
||||
const mockMatchAction = vi.fn()
|
||||
const mockSearchAnything = vi.fn()
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
vi.mock('../../actions', () => ({
|
||||
matchAction: (...args: unknown[]) => mockMatchAction(...args),
|
||||
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
|
||||
}))
|
||||
@@ -139,7 +139,6 @@ describe('useGotoAnythingResults', () => {
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
// Different types, same id = different keys, so both should remain
|
||||
expect(result.current.dedupedResults).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import type { ActionItem } from '../../actions/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingSearch } from './use-goto-anything-search'
|
||||
import { useGotoAnythingSearch } from '../use-goto-anything-search'
|
||||
|
||||
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
let mockMatchActionResult: Partial<ActionItem> | undefined
|
||||
@@ -9,11 +9,11 @@ vi.mock('ahooks', () => ({
|
||||
useDebounce: <T>(value: T) => value,
|
||||
}))
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
vi.mock('../../context', () => ({
|
||||
useGotoAnythingContext: () => mockContextValue,
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
vi.mock('../../actions', () => ({
|
||||
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
|
||||
const base = {
|
||||
slash: { key: '/', shortcut: '/' },
|
||||
@@ -233,13 +233,11 @@ describe('useGotoAnythingSearch', () => {
|
||||
it('should reset cmdVal to "_"', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
// First change cmdVal
|
||||
act(() => {
|
||||
result.current.setCmdVal('app-1')
|
||||
})
|
||||
expect(result.current.cmdVal).toBe('app-1')
|
||||
|
||||
// Then clear
|
||||
act(() => {
|
||||
result.current.clearSelection()
|
||||
})
|
||||
@@ -294,7 +292,6 @@ describe('useGotoAnythingSearch', () => {
|
||||
result.current.setSearchQuery(' test ')
|
||||
})
|
||||
|
||||
// Since we mock useDebounce to return value directly
|
||||
expect(result.current.searchQueryDebouncedValue).toBe('test')
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { env } from '@/env'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AccountAbout from '../account-about'
|
||||
@@ -178,7 +179,7 @@ export default function AppSelector() {
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{
|
||||
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
||||
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { SerwistProvider } from '@serwist/turbopack/react'
|
||||
import { useEffect } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { env } from '@/env'
|
||||
import { isClient } from '@/utils/client'
|
||||
|
||||
export function PWAProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -10,7 +11,7 @@ export function PWAProvider({ children }: { children: React.ReactNode }) {
|
||||
return <DisabledPWAProvider>{children}</DisabledPWAProvider>
|
||||
}
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
const basePath = env.NEXT_PUBLIC_BASE_PATH
|
||||
const swUrl = `${basePath}/serwist/sw.js`
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user