Compare commits

..

34 Commits

Author SHA1 Message Date
CodingOnStar
98d0ccc120 refactor(tests): update API key management and develop page tests for improved element selection and structure 2026-02-11 20:49:26 +08:00
CodingOnStar
f29e96ab4a feat(tests): add comprehensive tests for command selector and context management in goto-anything component 2026-02-11 18:22:30 +08:00
CodingOnStar
1f3014bbc4 feat(tests): add integration tests for API key management and develop page flow 2026-02-11 17:42:29 +08:00
Wu Tianwei
5b4c7b2a40 feat(tests): add mock for useInvalidateWorkflowRunHistory in pipeline run tests (#32234) 2026-02-11 14:51:43 +08:00
veganmosfet
378a1d7d08 Merge commit from fork
Removed the dangerous `new function` call during echarts parsing and replaced with an error message.

Co-authored-by: Byron Wang <byron@linux.com>
2026-02-11 14:22:30 +08:00
dependabot[bot]
ce0192620d chore(deps): bump google-api-python-client from 2.90.0 to 2.189.0 in /api (#32102)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 15:15:21 +09:00
dependabot[bot]
e9feeedc01 chore(deps): bump cryptography from 46.0.3 to 46.0.5 in /api (#32218)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 15:12:21 +09:00
Wu Tianwei
e32490f54e feat(workflow): enhance workflow run history management and UI updates (#32230) 2026-02-11 14:09:33 +08:00
Byron.wang
e9db50f781 docs(api): mark SetupApi as unauthenticated by design (#32224) 2026-02-11 12:11:09 +08:00
wangxiaolei
0310f631ee fix: fix get_message_event_type return wrong message type (#32019)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-11 10:57:27 +08:00
wangxiaolei
abc5a61e98 feat: support nl-NL language (#32216) 2026-02-11 10:42:13 +08:00
fenglin
5f1698add6 fix: add unique constraint to tenant_default_models to prevent duplic… (#31221)
Co-authored-by: qiaofenglin <qiaofenglin@baidu.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Novice <novice12185727@gmail.com>
2026-02-11 10:22:35 +08:00
wangxiaolei
36e50f277f fix: fix all tools is deleted (#32207) 2026-02-11 10:04:38 +08:00
QuantumGhost
704ee40caa fix(api): excessive high CPU usage caused by RedisClientWrapper (#32212)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-11 09:49:29 +08:00
QuantumGhost
3119c99979 chore(api): consume tasks in workflow_based_app_execution queue in start-worker script (#32214) 2026-02-11 09:21:54 +08:00
Wu Tianwei
16b8733886 fix: Fix the display of state icon of base node (#32208) 2026-02-10 22:45:56 +08:00
dependabot[bot]
83f64104fd chore(deps): bump axios from 1.13.2 to 1.13.5 in /sdks/nodejs-client (#32199)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 21:58:06 +08:00
非法操作
5077879886 chore: allow draft run single node without connect to other node (#31977) 2026-02-10 18:03:52 +08:00
weiguang li
697b57631a fix(console): keep conversation updated_at unchanged when marking read (#32133) 2026-02-10 17:56:38 +08:00
Ponder
6015f23e79 feat: enhancement celery configuration (#32145) 2026-02-10 17:55:24 +08:00
Stephen Zhou
f355c8d595 refactor: type safe env, update to zod v4 (#32035) 2026-02-10 17:55:11 +08:00
wangxiaolei
0142001fc2 fix: fix no dify home directory lead permission error (#32169) 2026-02-10 17:47:46 +08:00
Coding On Star
4058e9ae23 refactor: extract sub-components and custom hooks from UpdateDSLModal and Metadata components (#32045)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-02-10 17:26:08 +08:00
Novice
95310561ec chore(api): update launch.json.example to include new workflow_based_app_execution. (#32184) 2026-02-10 17:08:43 +08:00
Wu Tianwei
de33561a52 test: add comprehensive tests for Human Input Node functionality (#32191) 2026-02-10 17:00:46 +08:00
Varun Chawla
6d9665578b fix: replace sendBeacon with fetch keepalive for autosave on page close (#32088)
Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
2026-02-10 16:59:02 +08:00
weiguang li
18f14c04dc fix(web): fill workflow tool output descriptions from schema (#32117) 2026-02-10 16:51:28 +08:00
weiguang li
14251b249d fix(api): include file marker for workflow tool file outputs (#32114) 2026-02-10 16:51:12 +08:00
Stephen Zhou
1819bd72ef refactor: import component css in globals.css (#32180)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-10 13:55:42 +08:00
zyssyz123
7dabc03a08 fix: When the user is a non-sandbox user and has a paid balance, the … (#32173)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-10 12:08:23 +08:00
Dream
1a050c9f86 fix(api): clean up orphaned pending accounts on member removal (#32151)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-10 10:17:27 +08:00
Shuvam Pandey
7fb6e0cdfe refactor(api): tighten OTel decorator typing (#32163) 2026-02-10 00:46:02 +09:00
Stephen Zhou
e0fcf33979 chore: introduce css icons (#32004) 2026-02-09 18:37:41 +08:00
Stephen Zhou
898e09264b chore: detect utilities in css (#32143) 2026-02-09 18:20:09 +08:00
197 changed files with 16266 additions and 2998 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ from collections.abc import Generator
from copy import deepcopy
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from models.model import File
from core.tools.__base.tool_runtime import ToolRuntime
@@ -171,7 +171,7 @@ class Tool(ABC):
def create_file_message(self, file: File) -> ToolInvokeMessage:
return ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.FILE,
message=ToolInvokeMessage.FileMessage(),
message=ToolInvokeMessage.FileMessage(file_marker="file_marker"),
meta={"file": file},
)

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import functools
from collections.abc import Callable
from typing import Any, TypeVar, cast
from typing import ParamSpec, TypeVar, cast
from opentelemetry.trace import get_tracer
@@ -8,7 +8,8 @@ from configs import dify_config
from extensions.otel.decorators.handler import SpanHandler
from extensions.otel.runtime import is_instrument_flag_enabled
T = TypeVar("T", bound=Callable[..., Any])
P = ParamSpec("P")
R = TypeVar("R")
_HANDLER_INSTANCES: dict[type[SpanHandler], SpanHandler] = {SpanHandler: SpanHandler()}
@@ -20,7 +21,7 @@ def _get_handler_instance(handler_class: type[SpanHandler]) -> SpanHandler:
return _HANDLER_INSTANCES[handler_class]
def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T], T]:
def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""
Decorator that traces a function with an OpenTelemetry span.
@@ -30,9 +31,9 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
:param handler_class: Optional handler class to use for this span. If None, uses the default SpanHandler.
"""
def decorator(func: T) -> T:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()):
return func(*args, **kwargs)
@@ -46,6 +47,6 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
kwargs=kwargs,
)
return cast(T, wrapper)
return cast(Callable[P, R], wrapper)
return decorator

View File

@@ -1,9 +1,11 @@
import inspect
from collections.abc import Callable, Mapping
from typing import Any
from typing import Any, TypeVar
from opentelemetry.trace import SpanKind, Status, StatusCode
R = TypeVar("R")
class SpanHandler:
"""
@@ -31,9 +33,9 @@ class SpanHandler:
def _extract_arguments(
self,
wrapped: Callable[..., Any],
args: tuple[Any, ...],
kwargs: Mapping[str, Any],
wrapped: Callable[..., R],
args: tuple[object, ...],
kwargs: Mapping[str, object],
) -> dict[str, Any] | None:
"""
Extract function arguments using inspect.signature.
@@ -62,10 +64,10 @@ class SpanHandler:
def wrapper(
self,
tracer: Any,
wrapped: Callable[..., Any],
args: tuple[Any, ...],
kwargs: Mapping[str, Any],
) -> Any:
wrapped: Callable[..., R],
args: tuple[object, ...],
kwargs: Mapping[str, object],
) -> R:
"""
Fully control the wrapper behavior.

View File

@@ -1,6 +1,6 @@
import logging
from collections.abc import Callable, Mapping
from typing import Any
from typing import Any, TypeVar
from opentelemetry.trace import SpanKind, Status, StatusCode
from opentelemetry.util.types import AttributeValue
@@ -12,16 +12,19 @@ from models.model import Account
logger = logging.getLogger(__name__)
R = TypeVar("R")
class AppGenerateHandler(SpanHandler):
"""Span handler for ``AppGenerateService.generate``."""
def wrapper(
self,
tracer: Any,
wrapped: Callable[..., Any],
args: tuple[Any, ...],
kwargs: Mapping[str, Any],
) -> Any:
wrapped: Callable[..., R],
args: tuple[object, ...],
kwargs: Mapping[str, object],
) -> R:
try:
arguments = self._extract_arguments(wrapped, args, kwargs)
if not arguments:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1225,7 +1225,12 @@ class TenantService:
@staticmethod
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
"""Remove member from tenant"""
"""Remove member from tenant.
If the removed member has ``AccountStatus.PENDING`` (invited but never
activated) and no remaining workspace memberships, the orphaned account
record is deleted as well.
"""
if operator.id == account.id:
raise CannotOperateSelfError("Cannot operate self.")
@@ -1235,9 +1240,31 @@ class TenantService:
if not ta:
raise MemberNotInTenantError("Member not in tenant.")
# Capture identifiers before any deletions; attribute access on the ORM
# object may fail after commit() expires the instance.
account_id = account.id
account_email = account.email
db.session.delete(ta)
# Clean up orphaned pending accounts (invited but never activated)
should_delete_account = False
if account.status == AccountStatus.PENDING:
# autoflush flushes ta deletion before this query, so 0 means no remaining joins
remaining_joins = db.session.query(TenantAccountJoin).filter_by(account_id=account_id).count()
if remaining_joins == 0:
db.session.delete(account)
should_delete_account = True
db.session.commit()
if should_delete_account:
logger.info(
"Deleted orphaned pending account: account_id=%s, email=%s",
account_id,
account_email,
)
if dify_config.BILLING_ENABLED:
BillingService.clean_billing_info_cache(tenant.id)
@@ -1245,13 +1272,13 @@ class TenantService:
from services.enterprise.account_deletion_sync import sync_workspace_member_removal
sync_success = sync_workspace_member_removal(
workspace_id=tenant.id, member_id=account.id, source="workspace_member_removed"
workspace_id=tenant.id, member_id=account_id, source="workspace_member_removed"
)
if not sync_success:
logger.warning(
"Enterprise workspace member removal sync failed: workspace_id=%s, member_id=%s",
tenant.id,
account.id,
account_id,
)
@staticmethod

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
from flask_login import current_user
from configs import dify_config
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
from services.account_service import TenantService
@@ -53,7 +54,12 @@ class WorkspaceService:
from services.credit_pool_service import CreditPoolService
paid_pool = CreditPoolService.get_pool(tenant_id=tenant.id, pool_type="paid")
if paid_pool:
# if the tenant is not on the sandbox plan and the paid pool is not full, use the paid pool
if (
feature.billing.subscription.plan != CloudPlan.SANDBOX
and paid_pool is not None
and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used)
):
tenant_info["trial_credits"] = paid_pool.quota_limit
tenant_info["trial_credits_used"] = paid_pool.quota_used
else:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
from typing import Any, cast
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
class DummyCastType:
def cast_value(self, value: Any) -> str:
return f"cast:{value}"
@dataclass
class DummyParameter:
name: str
type: DummyCastType
form: str = "llm"
required: bool = False
default: Any = None
options: list[Any] | None = None
llm_description: str | None = None
class DummyTool(Tool):
def __init__(self, entity: ToolEntity, runtime: ToolRuntime):
super().__init__(entity=entity, runtime=runtime)
self.result: ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None] = (
self.create_text_message("default")
)
self.runtime_parameter_overrides: list[Any] | None = None
self.last_invocation: dict[str, Any] | None = None
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.BUILT_IN
def _invoke(
self,
user_id: str,
tool_parameters: dict[str, Any],
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]:
self.last_invocation = {
"user_id": user_id,
"tool_parameters": tool_parameters,
"conversation_id": conversation_id,
"app_id": app_id,
"message_id": message_id,
}
return self.result
def get_runtime_parameters(
self,
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
):
if self.runtime_parameter_overrides is not None:
return self.runtime_parameter_overrides
return super().get_runtime_parameters(
conversation_id=conversation_id,
app_id=app_id,
message_id=message_id,
)
def _build_tool(runtime: ToolRuntime | None = None) -> DummyTool:
entity = ToolEntity(
identity=ToolIdentity(author="test", name="dummy", label=I18nObject(en_US="dummy"), provider="test"),
parameters=[],
description=None,
has_runtime_parameters=False,
)
runtime = runtime or ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER, runtime_parameters={})
return DummyTool(entity=entity, runtime=runtime)
def test_invoke_supports_single_message_and_parameter_casting():
runtime = ToolRuntime(
tenant_id="tenant-1",
invoke_from=InvokeFrom.DEBUGGER,
runtime_parameters={"from_runtime": "runtime-value"},
)
tool = _build_tool(runtime)
tool.entity.parameters = cast(
Any,
[
DummyParameter(name="unused", type=DummyCastType()),
DummyParameter(name="age", type=DummyCastType()),
],
)
tool.result = tool.create_text_message("ok")
messages = list(
tool.invoke(
user_id="user-1",
tool_parameters={"age": "18", "raw": "keep"},
conversation_id="conv-1",
app_id="app-1",
message_id="msg-1",
)
)
assert len(messages) == 1
assert messages[0].message.text == "ok"
assert tool.last_invocation == {
"user_id": "user-1",
"tool_parameters": {"age": "cast:18", "raw": "keep", "from_runtime": "runtime-value"},
"conversation_id": "conv-1",
"app_id": "app-1",
"message_id": "msg-1",
}
def test_invoke_supports_list_and_generator_results():
tool = _build_tool()
tool.result = [tool.create_text_message("a"), tool.create_text_message("b")]
list_messages = list(tool.invoke(user_id="user-1", tool_parameters={}))
assert [msg.message.text for msg in list_messages] == ["a", "b"]
def _message_generator() -> Generator[ToolInvokeMessage, None, None]:
yield tool.create_text_message("g1")
yield tool.create_text_message("g2")
tool.result = _message_generator()
generated_messages = list(tool.invoke(user_id="user-2", tool_parameters={}))
assert [msg.message.text for msg in generated_messages] == ["g1", "g2"]
def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
tool = _build_tool()
new_runtime = ToolRuntime(tenant_id="tenant-2", invoke_from=InvokeFrom.EXPLORE, runtime_parameters={})
forked = tool.fork_tool_runtime(new_runtime)
assert isinstance(forked, DummyTool)
assert forked is not tool
assert forked.runtime == new_runtime
assert forked.entity == tool.entity
assert forked.entity is not tool.entity
def test_get_runtime_parameters_and_merge_runtime_parameters():
tool = _build_tool()
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
tool.entity.parameters = cast(Any, [original])
default_runtime_parameters = tool.get_runtime_parameters()
assert default_runtime_parameters == [original]
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
tool.runtime_parameter_overrides = [override, appended]
merged = tool.get_merged_runtime_parameters()
assert len(merged) == 2
assert merged[0].name == "temperature"
assert merged[0].form == "llm"
assert merged[0].required is False
assert merged[0].default == "0.5"
assert merged[1].name == "new_param"
def test_message_factory_helpers():
tool = _build_tool()
image_message = tool.create_image_message("https://example.com/image.png")
assert image_message.type == ToolInvokeMessage.MessageType.IMAGE
assert image_message.message.text == "https://example.com/image.png"
file_obj = object()
file_message = tool.create_file_message(file_obj) # type: ignore[arg-type]
assert file_message.type == ToolInvokeMessage.MessageType.FILE
assert file_message.message.file_marker == "file_marker"
assert file_message.meta == {"file": file_obj}
link_message = tool.create_link_message("https://example.com")
assert link_message.type == ToolInvokeMessage.MessageType.LINK
assert link_message.message.text == "https://example.com"
text_message = tool.create_text_message("hello")
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
assert text_message.message.text == "hello"
blob_message = tool.create_blob_message(b"blob", meta={"source": "unit-test"})
assert blob_message.type == ToolInvokeMessage.MessageType.BLOB
assert blob_message.message.blob == b"blob"
assert blob_message.meta == {"source": "unit-test"}
json_message = tool.create_json_message({"k": "v"}, suppress_output=True)
assert json_message.type == ToolInvokeMessage.MessageType.JSON
assert json_message.message.json_object == {"k": "v"}
assert json_message.message.suppress_output is True
variable_message = tool.create_variable_message("answer", 42, stream=False)
assert variable_message.type == ToolInvokeMessage.MessageType.VARIABLE
assert variable_message.message.variable_name == "answer"
assert variable_message.message.variable_value == 42
assert variable_message.message.stream is False
def test_base_abstract_invoke_placeholder_returns_none():
tool = _build_tool()
assert Tool._invoke(tool, user_id="u", tool_parameters={}) is None

View File

@@ -255,6 +255,32 @@ def test_create_variable_message():
assert message.message.stream is False
def test_create_file_message_should_include_file_marker():
entity = ToolEntity(
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
parameters=[],
description=None,
has_runtime_parameters=False,
)
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
tool = WorkflowTool(
workflow_app_id="",
workflow_as_tool_id="",
version="1",
workflow_entities={},
workflow_call_depth=1,
entity=entity,
runtime=runtime,
)
file_obj = object()
message = tool.create_file_message(file_obj) # type: ignore[arg-type]
assert message.type == ToolInvokeMessage.MessageType.FILE
assert message.message.file_marker == "file_marker"
assert message.meta == {"file": file_obj}
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
"""Ensure worker context can resolve EndUser when Account is missing."""

View File

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

View File

@@ -698,6 +698,132 @@ class TestTenantService:
self._assert_database_operations_called(mock_db_dependencies["db"])
# ==================== Member Removal Tests ====================
def test_remove_pending_member_deletes_orphaned_account(self):
"""Test that removing a pending member with no other workspaces deletes the account."""
# Arrange
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
mock_pending_member = TestAccountAssociatedDataFactory.create_account_mock(
account_id="pending-user-789", email="pending@example.com", status=AccountStatus.PENDING
)
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="pending-user-789", role="normal"
)
with patch("services.account_service.db") as mock_db:
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="owner"
)
query_mock_permission = MagicMock()
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
query_mock_ta = MagicMock()
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
query_mock_count = MagicMock()
query_mock_count.filter_by.return_value.count.return_value = 0
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
mock_sync.return_value = True
# Act
TenantService.remove_member_from_tenant(mock_tenant, mock_pending_member, mock_operator)
# Assert: enterprise sync still receives the correct member ID
mock_sync.assert_called_once_with(
workspace_id="tenant-456",
member_id="pending-user-789",
source="workspace_member_removed",
)
# Assert: both join record and account should be deleted
mock_db.session.delete.assert_any_call(mock_ta)
mock_db.session.delete.assert_any_call(mock_pending_member)
assert mock_db.session.delete.call_count == 2
def test_remove_pending_member_keeps_account_with_other_workspaces(self):
"""Test that removing a pending member who belongs to other workspaces preserves the account."""
# Arrange
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
mock_pending_member = TestAccountAssociatedDataFactory.create_account_mock(
account_id="pending-user-789", email="pending@example.com", status=AccountStatus.PENDING
)
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="pending-user-789", role="normal"
)
with patch("services.account_service.db") as mock_db:
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="owner"
)
query_mock_permission = MagicMock()
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
query_mock_ta = MagicMock()
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
# Remaining join count = 1 (still in another workspace)
query_mock_count = MagicMock()
query_mock_count.filter_by.return_value.count.return_value = 1
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
mock_sync.return_value = True
# Act
TenantService.remove_member_from_tenant(mock_tenant, mock_pending_member, mock_operator)
# Assert: only the join record should be deleted, not the account
mock_db.session.delete.assert_called_once_with(mock_ta)
def test_remove_active_member_preserves_account(self):
"""Test that removing an active member never deletes the account, even with no other workspaces."""
# Arrange
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123", role="owner")
mock_active_member = TestAccountAssociatedDataFactory.create_account_mock(
account_id="active-user-789", email="active@example.com", status=AccountStatus.ACTIVE
)
mock_ta = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="active-user-789", role="normal"
)
with patch("services.account_service.db") as mock_db:
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="owner"
)
query_mock_permission = MagicMock()
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
query_mock_ta = MagicMock()
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta]
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
mock_sync.return_value = True
# Act
TenantService.remove_member_from_tenant(mock_tenant, mock_active_member, mock_operator)
# Assert: only the join record should be deleted
mock_db.session.delete.assert_called_once_with(mock_ta)
# ==================== Tenant Switching Tests ====================
def test_switch_tenant_success(self):

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import './index.css'
enum BadgeState {
Warning = 'warning',

View File

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

View File

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

View File

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

View File

@@ -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[]) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
import * as React from 'react'
import { Highlight } from '@/app/components/base/icons/src/public/common'
import { cn } from '@/utils/classnames'
import './index.css'
const PremiumBadgeVariants = cva(
'premium-badge',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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