Compare commits

...

27 Commits

Author SHA1 Message Date
CodingOnStar
af1c4ffbfb Merge remote-tracking branch 'origin/main' into test/integrate-pipeline 2026-02-11 15:40:49 +08:00
CodingOnStar
7058f90fd1 test: add comprehensive unit tests for RagPipeline components including conversion, publish modal, and toast notifications 2026-02-11 15:38:42 +08:00
CodingOnStar
cebf141525 test: add integration tests for DSL export/import flow, input field CRUD, and test run flow 2026-02-11 15:11:48 +08:00
Wu Tianwei
5b4c7b2a40 feat(tests): add mock for useInvalidateWorkflowRunHistory in pipeline run tests (#32234) 2026-02-11 14:51:43 +08:00
CodingOnStar
faefb98746 test: add integration tests for chunk preview formatting and input field editor flow 2026-02-11 14:50:32 +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)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
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)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
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)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
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
182 changed files with 13043 additions and 6846 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

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

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

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

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

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

@@ -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,210 @@
/**
* Integration test: Chunk preview formatting pipeline
*
* Tests the formatPreviewChunks utility across all chunking modes
* (text, parentChild, QA) with real data structures.
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3,
}))
vi.mock('@/models/datasets', () => ({
ChunkingMode: {
text: 'text',
parentChild: 'parent-child',
qa: 'qa',
},
}))
const { formatPreviewChunks } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils',
)
describe('Chunk Preview Formatting', () => {
describe('general text chunks', () => {
it('should format text chunks correctly', () => {
const outputs = {
chunk_structure: 'text',
preview: [
{ content: 'Chunk 1 content', summary: 'Summary 1' },
{ content: 'Chunk 2 content' },
],
}
const result = formatPreviewChunks(outputs)
expect(Array.isArray(result)).toBe(true)
const chunks = result as Array<{ content: string, summary?: string }>
expect(chunks).toHaveLength(2)
expect(chunks[0].content).toBe('Chunk 1 content')
expect(chunks[0].summary).toBe('Summary 1')
expect(chunks[1].content).toBe('Chunk 2 content')
})
it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => {
const outputs = {
chunk_structure: 'text',
preview: Array.from({ length: 10 }, (_, i) => ({
content: `Chunk ${i + 1}`,
})),
}
const result = formatPreviewChunks(outputs)
const chunks = result as Array<{ content: string }>
expect(chunks).toHaveLength(3) // Mocked limit
})
})
describe('parent-child chunks — paragraph mode', () => {
it('should format paragraph parent-child chunks', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'paragraph',
preview: [
{
content: 'Parent paragraph',
child_chunks: ['Child 1', 'Child 2'],
summary: 'Parent summary',
},
],
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: Array<{
parent_content: string
parent_summary?: string
child_contents: string[]
parent_mode: string
}>
parent_mode: string
}
expect(result.parent_mode).toBe('paragraph')
expect(result.parent_child_chunks).toHaveLength(1)
expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph')
expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary')
expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2'])
})
it('should limit parent chunks in paragraph mode', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'paragraph',
preview: Array.from({ length: 10 }, (_, i) => ({
content: `Parent ${i + 1}`,
child_chunks: [`Child of ${i + 1}`],
})),
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: unknown[]
}
expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit
})
})
describe('parent-child chunks — full-doc mode', () => {
it('should format full-doc parent-child chunks', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'full-doc',
preview: [
{
content: 'Full document content',
child_chunks: ['Section 1', 'Section 2', 'Section 3'],
},
],
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: Array<{
parent_content: string
child_contents: string[]
parent_mode: string
}>
}
expect(result.parent_child_chunks).toHaveLength(1)
expect(result.parent_child_chunks[0].parent_content).toBe('Full document content')
expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc')
})
it('should limit child chunks in full-doc mode', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'full-doc',
preview: [
{
content: 'Document',
child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`),
},
],
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: Array<{ child_contents: string[] }>
}
expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit
})
})
describe('QA chunks', () => {
it('should format QA chunks correctly', () => {
const outputs = {
chunk_structure: 'qa',
qa_preview: [
{ question: 'What is AI?', answer: 'Artificial Intelligence is...' },
{ question: 'What is ML?', answer: 'Machine Learning is...' },
],
}
const result = formatPreviewChunks(outputs) as {
qa_chunks: Array<{ question: string, answer: string }>
}
expect(result.qa_chunks).toHaveLength(2)
expect(result.qa_chunks[0].question).toBe('What is AI?')
expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...')
})
it('should limit QA chunks', () => {
const outputs = {
chunk_structure: 'qa',
qa_preview: Array.from({ length: 10 }, (_, i) => ({
question: `Q${i + 1}`,
answer: `A${i + 1}`,
})),
}
const result = formatPreviewChunks(outputs) as {
qa_chunks: unknown[]
}
expect(result.qa_chunks).toHaveLength(3) // Mocked limit
})
})
describe('edge cases', () => {
it('should return undefined for null outputs', () => {
expect(formatPreviewChunks(null)).toBeUndefined()
})
it('should return undefined for undefined outputs', () => {
expect(formatPreviewChunks(undefined)).toBeUndefined()
})
it('should return undefined for unknown chunk_structure', () => {
const outputs = {
chunk_structure: 'unknown-type',
preview: [],
}
expect(formatPreviewChunks(outputs)).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,179 @@
/**
* Integration test: DSL export/import flow
*
* Validates DSL export logic (sync draft → check secrets → download)
* and DSL import modal state management.
*/
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
const mockNotify = vi.fn()
const mockEventEmitter = { emit: vi.fn() }
const mockDownloadBlob = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
pipelineId: 'pipeline-abc',
knowledgeName: 'My Pipeline',
}),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipelineConfig,
}),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
describe('DSL Export/Import Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Export Flow', () => {
it('should sync draft then export then download', async () => {
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'pipeline-abc',
include: false,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'My Pipeline.pipeline',
}))
})
it('should export with include flag when specified', async () => {
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true)
})
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'pipeline-abc',
include: true,
})
})
it('should notify on export error', async () => {
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
describe('Export Check Flow', () => {
it('should export directly when no secret environment variables', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
environment_variables: [
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
],
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should proceed to export directly (no secret vars)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
environment_variables: [
{ value_type: 'secret', key: 'API_KEY', value: '***' },
],
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
type: 'DSL_EXPORT_CHECK',
payload: expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({ value_type: 'secret' }),
]),
}),
}))
})
it('should notify on export check error', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
})

View File

@@ -0,0 +1,278 @@
/**
* Integration test: Input field CRUD complete flow
*
* Validates the full lifecycle of input fields:
* creation, editing, renaming, removal, and data conversion round-trip.
*/
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { InputVar } from '@/models/pipeline'
import { describe, expect, it, vi } from 'vitest'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { TransferMethod } from '@/types/app'
vi.mock('@/config', () => ({
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
type: 'text-input',
label: '',
variable: '',
max_length: 48,
default_value: undefined,
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
},
}))
describe('Input Field CRUD Flow', () => {
describe('Create → Edit → Convert Round-trip', () => {
it('should create a text field and roundtrip through form data', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
// Create new field from template (no data passed)
const newFormData = convertToInputFieldFormData()
expect(newFormData.type).toBe('text-input')
expect(newFormData.variable).toBe('')
expect(newFormData.label).toBe('')
expect(newFormData.required).toBe(true)
// Simulate user editing form data
const editedFormData: FormData = {
...newFormData,
variable: 'user_name',
label: 'User Name',
maxLength: 100,
default: 'John',
tooltips: 'Enter your name',
placeholder: 'Type here...',
allowedTypesAndExtensions: {},
}
// Convert back to InputVar
const inputVar = convertFormDataToINputField(editedFormData)
expect(inputVar.variable).toBe('user_name')
expect(inputVar.label).toBe('User Name')
expect(inputVar.max_length).toBe(100)
expect(inputVar.default_value).toBe('John')
expect(inputVar.tooltips).toBe('Enter your name')
expect(inputVar.placeholder).toBe('Type here...')
expect(inputVar.required).toBe(true)
})
it('should handle file field with upload settings', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const fileInputVar: InputVar = {
type: PipelineInputVarType.singleFile,
label: 'Upload Document',
variable: 'doc_file',
max_length: 1,
default_value: undefined,
required: true,
tooltips: 'Upload a PDF',
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['.pdf', '.docx'],
}
// Convert to form data
const formData = convertToInputFieldFormData(fileInputVar)
expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
expect(formData.allowedTypesAndExtensions).toEqual({
allowedFileTypes: [SupportUploadFileTypes.document],
allowedFileExtensions: ['.pdf', '.docx'],
})
// Round-trip back
const restored = convertFormDataToINputField(formData)
expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document])
expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx'])
})
it('should handle select field with options', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const selectVar: InputVar = {
type: PipelineInputVarType.select,
label: 'Priority',
variable: 'priority',
max_length: 0,
default_value: 'medium',
required: false,
tooltips: 'Select priority level',
options: ['low', 'medium', 'high'],
placeholder: 'Choose...',
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(selectVar)
expect(formData.options).toEqual(['low', 'medium', 'high'])
expect(formData.default).toBe('medium')
const restored = convertFormDataToINputField(formData)
expect(restored.options).toEqual(['low', 'medium', 'high'])
expect(restored.default_value).toBe('medium')
})
it('should handle number field with unit', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const numberVar: InputVar = {
type: PipelineInputVarType.number,
label: 'Max Tokens',
variable: 'max_tokens',
max_length: 0,
default_value: '1024',
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: 'tokens',
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(numberVar)
expect(formData.unit).toBe('tokens')
expect(formData.default).toBe('1024')
const restored = convertFormDataToINputField(formData)
expect(restored.unit).toBe('tokens')
expect(restored.default_value).toBe('1024')
})
})
describe('Omit optional fields', () => {
it('should not include tooltips when undefined', async () => {
const { convertToInputFieldFormData } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const inputVar: InputVar = {
type: PipelineInputVarType.textInput,
label: 'Test',
variable: 'test',
max_length: 48,
default_value: undefined,
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(inputVar)
// Optional fields should not be present
expect('tooltips' in formData).toBe(false)
expect('placeholder' in formData).toBe(false)
expect('unit' in formData).toBe(false)
expect('default' in formData).toBe(false)
})
it('should include optional fields when explicitly set to empty string', async () => {
const { convertToInputFieldFormData } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const inputVar: InputVar = {
type: PipelineInputVarType.textInput,
label: 'Test',
variable: 'test',
max_length: 48,
default_value: '',
required: true,
tooltips: '',
options: [],
placeholder: '',
unit: '',
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(inputVar)
expect(formData.default).toBe('')
expect(formData.tooltips).toBe('')
expect(formData.placeholder).toBe('')
expect(formData.unit).toBe('')
})
})
describe('Multiple fields workflow', () => {
it('should process multiple fields independently', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const fields: InputVar[] = [
{
type: PipelineInputVarType.textInput,
label: 'Name',
variable: 'name',
max_length: 48,
default_value: 'Alice',
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
},
{
type: PipelineInputVarType.number,
label: 'Count',
variable: 'count',
max_length: 0,
default_value: '10',
required: false,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: 'items',
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
},
]
const formDataList = fields.map(f => convertToInputFieldFormData(f))
const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd))
expect(restoredFields).toHaveLength(2)
expect(restoredFields[0].variable).toBe('name')
expect(restoredFields[0].default_value).toBe('Alice')
expect(restoredFields[1].variable).toBe('count')
expect(restoredFields[1].default_value).toBe('10')
expect(restoredFields[1].unit).toBe('items')
})
})
})

View File

@@ -0,0 +1,199 @@
/**
* Integration test: Input field editor data conversion flow
*
* Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip
* and schema validation for various input types.
*/
import type { InputVar } from '@/models/pipeline'
import { describe, expect, it, vi } from 'vitest'
import { PipelineInputVarType } from '@/models/pipeline'
// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE
vi.mock('@/config', () => ({
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
type: 'text-input',
label: '',
variable: '',
max_length: 48,
required: false,
options: [],
allowed_file_upload_methods: [],
allowed_file_types: [],
allowed_file_extensions: [],
},
MAX_VAR_KEY_LENGTH: 30,
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10,
}))
// Import real functions (not mocked)
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
describe('Input Field Editor Data Flow', () => {
describe('convertToInputFieldFormData', () => {
it('should convert a text input InputVar to FormData', () => {
const inputVar: InputVar = {
type: 'text-input',
label: 'Name',
variable: 'user_name',
max_length: 100,
required: true,
default_value: 'John',
tooltips: 'Enter your name',
placeholder: 'Type here...',
options: [],
} as InputVar
const formData = convertToInputFieldFormData(inputVar)
expect(formData.type).toBe('text-input')
expect(formData.label).toBe('Name')
expect(formData.variable).toBe('user_name')
expect(formData.maxLength).toBe(100)
expect(formData.required).toBe(true)
expect(formData.default).toBe('John')
expect(formData.tooltips).toBe('Enter your name')
expect(formData.placeholder).toBe('Type here...')
})
it('should handle file input with upload settings', () => {
const inputVar: InputVar = {
type: 'file',
label: 'Document',
variable: 'doc',
required: false,
allowed_file_upload_methods: ['local_file', 'remote_url'],
allowed_file_types: ['document', 'image'],
allowed_file_extensions: ['.pdf', '.jpg'],
options: [],
} as InputVar
const formData = convertToInputFieldFormData(inputVar)
expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url'])
expect(formData.allowedTypesAndExtensions).toEqual({
allowedFileTypes: ['document', 'image'],
allowedFileExtensions: ['.pdf', '.jpg'],
})
})
it('should use template defaults when no data provided', () => {
const formData = convertToInputFieldFormData(undefined)
expect(formData.type).toBe('text-input')
expect(formData.maxLength).toBe(48)
expect(formData.required).toBe(false)
})
it('should omit undefined/null optional fields', () => {
const inputVar: InputVar = {
type: 'text-input',
label: 'Simple',
variable: 'simple_var',
max_length: 50,
required: false,
options: [],
} as InputVar
const formData = convertToInputFieldFormData(inputVar)
expect(formData.default).toBeUndefined()
expect(formData.tooltips).toBeUndefined()
expect(formData.placeholder).toBeUndefined()
expect(formData.unit).toBeUndefined()
})
})
describe('convertFormDataToINputField', () => {
it('should convert FormData back to InputVar', () => {
const formData = {
type: PipelineInputVarType.textInput,
label: 'Name',
variable: 'user_name',
maxLength: 100,
required: true,
default: 'John',
tooltips: 'Enter your name',
options: [],
placeholder: 'Type here...',
allowedTypesAndExtensions: {
allowedFileTypes: undefined,
allowedFileExtensions: undefined,
},
}
const inputVar = convertFormDataToINputField(formData)
expect(inputVar.type).toBe('text-input')
expect(inputVar.label).toBe('Name')
expect(inputVar.variable).toBe('user_name')
expect(inputVar.max_length).toBe(100)
expect(inputVar.required).toBe(true)
expect(inputVar.default_value).toBe('John')
expect(inputVar.tooltips).toBe('Enter your name')
})
})
describe('roundtrip conversion', () => {
it('should preserve text input data through roundtrip', () => {
const original: InputVar = {
type: 'text-input',
label: 'Question',
variable: 'question',
max_length: 200,
required: true,
default_value: 'What is AI?',
tooltips: 'Enter your question',
placeholder: 'Ask something...',
options: [],
} as InputVar
const formData = convertToInputFieldFormData(original)
const restored = convertFormDataToINputField(formData)
expect(restored.type).toBe(original.type)
expect(restored.label).toBe(original.label)
expect(restored.variable).toBe(original.variable)
expect(restored.max_length).toBe(original.max_length)
expect(restored.required).toBe(original.required)
expect(restored.default_value).toBe(original.default_value)
expect(restored.tooltips).toBe(original.tooltips)
expect(restored.placeholder).toBe(original.placeholder)
})
it('should preserve number input data through roundtrip', () => {
const original = {
type: 'number',
label: 'Temperature',
variable: 'temp',
required: false,
default_value: '0.7',
unit: '°C',
options: [],
} as InputVar
const formData = convertToInputFieldFormData(original)
const restored = convertFormDataToINputField(formData)
expect(restored.type).toBe('number')
expect(restored.unit).toBe('°C')
expect(restored.default_value).toBe('0.7')
})
it('should preserve select options through roundtrip', () => {
const original: InputVar = {
type: 'select',
label: 'Mode',
variable: 'mode',
required: true,
options: ['fast', 'balanced', 'quality'],
} as InputVar
const formData = convertToInputFieldFormData(original)
const restored = convertFormDataToINputField(formData)
expect(restored.options).toEqual(['fast', 'balanced', 'quality'])
})
})
})

View File

@@ -0,0 +1,282 @@
/**
* Integration test: Test run end-to-end flow
*
* Validates the data flow through test-run preparation hooks:
* step navigation, datasource filtering, and data clearing.
*/
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Use string literals inside vi.hoisted to avoid import-before-init
// BlockEnum.DataSource = 'datasource', BlockEnum.KnowledgeBase = 'knowledge-base'
const mockNodes = vi.hoisted(() => [
{
id: 'ds-1',
data: {
type: 'datasource',
title: 'Local Files',
datasource_type: 'upload_file',
datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} },
},
},
{
id: 'ds-2',
data: {
type: 'datasource',
title: 'Web Crawl',
datasource_type: 'website_crawl',
datasource_configurations: { datasource_label: 'Crawl' },
},
},
{
id: 'kb-1',
data: {
type: 'knowledge-base',
title: 'Knowledge Base',
},
},
])
vi.mock('reactflow', () => ({
useNodes: () => mockNodes,
}))
// Mock the Zustand store used by the hooks
const mockSetDocumentsData = vi.fn()
const mockSetSearchValue = vi.fn()
const mockSetSelectedPagesId = vi.fn()
const mockSetOnlineDocuments = vi.fn()
const mockSetCurrentDocument = vi.fn()
const mockSetStep = vi.fn()
const mockSetCrawlResult = vi.fn()
const mockSetWebsitePages = vi.fn()
const mockSetPreviewIndex = vi.fn()
const mockSetCurrentWebsite = vi.fn()
const mockSetOnlineDriveFileList = vi.fn()
const mockSetBucket = vi.fn()
const mockSetPrefix = vi.fn()
const mockSetKeywords = vi.fn()
const mockSetSelectedFileIds = vi.fn()
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
useDataSourceStore: () => ({
getState: () => ({
setDocumentsData: mockSetDocumentsData,
setSearchValue: mockSetSearchValue,
setSelectedPagesId: mockSetSelectedPagesId,
setOnlineDocuments: mockSetOnlineDocuments,
setCurrentDocument: mockSetCurrentDocument,
setStep: mockSetStep,
setCrawlResult: mockSetCrawlResult,
setWebsitePages: mockSetWebsitePages,
setPreviewIndex: mockSetPreviewIndex,
setCurrentWebsite: mockSetCurrentWebsite,
setOnlineDriveFileList: mockSetOnlineDriveFileList,
setBucket: mockSetBucket,
setPrefix: mockSetPrefix,
setKeywords: mockSetKeywords,
setSelectedFileIds: mockSetSelectedFileIds,
}),
}),
}))
vi.mock('@/app/components/rag-pipeline/components/panel/test-run/types', () => ({
TestRunStep: {
dataSource: 'data_source',
documentProcessing: 'document_processing',
},
}))
vi.mock('@/models/datasets', () => ({
CrawlStep: {
init: 'init',
},
}))
describe('Test Run Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Step Navigation', () => {
it('should start at step 1 and navigate forward', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.currentStep).toBe(1)
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should navigate back from step 2 to step 1', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(1)
})
it('should provide labeled steps', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.steps).toHaveLength(2)
expect(result.current.steps[0].value).toBe('data_source')
expect(result.current.steps[1].value).toBe('document_processing')
})
})
describe('Datasource Options', () => {
it('should filter nodes to only DataSource type', async () => {
const { useDatasourceOptions } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useDatasourceOptions())
// Should only include DataSource nodes, not KnowledgeBase
expect(result.current).toHaveLength(2)
expect(result.current[0].value).toBe('ds-1')
expect(result.current[1].value).toBe('ds-2')
})
it('should include node data in options', async () => {
const { useDatasourceOptions } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useDatasourceOptions())
expect(result.current[0].label).toBe('Local Files')
expect(result.current[0].data.type).toBe('datasource')
})
})
describe('Data Clearing Flow', () => {
it('should clear online document data', async () => {
const { useOnlineDocument } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useOnlineDocument())
act(() => {
result.current.clearOnlineDocumentData()
})
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
expect(mockSetSearchValue).toHaveBeenCalledWith('')
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set))
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
})
it('should clear website crawl data', async () => {
const { useWebsiteCrawl } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useWebsiteCrawl())
act(() => {
result.current.clearWebsiteCrawlData()
})
expect(mockSetStep).toHaveBeenCalledWith('init')
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
})
it('should clear online drive data', async () => {
const { useOnlineDrive } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useOnlineDrive())
act(() => {
result.current.clearOnlineDriveData()
})
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
expect(mockSetBucket).toHaveBeenCalledWith('')
expect(mockSetPrefix).toHaveBeenCalledWith([])
expect(mockSetKeywords).toHaveBeenCalledWith('')
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
})
})
describe('Full Flow Simulation', () => {
it('should support complete step navigation cycle', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
// Start at step 1
expect(result.current.currentStep).toBe(1)
// Move to step 2
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
// Go back to step 1
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(1)
// Move forward again
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should not regress when clearing all data sources in sequence', async () => {
const {
useOnlineDocument,
useWebsiteCrawl,
useOnlineDrive,
} = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result: docResult } = renderHook(() => useOnlineDocument())
const { result: crawlResult } = renderHook(() => useWebsiteCrawl())
const { result: driveResult } = renderHook(() => useOnlineDrive())
// Clear all data sources
act(() => {
docResult.current.clearOnlineDocumentData()
crawlResult.current.clearWebsiteCrawlData()
driveResult.current.clearOnlineDriveData()
})
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
expect(mockSetStep).toHaveBeenCalledWith('init')
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
})
})
})

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

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

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

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

View File

@@ -3,45 +3,36 @@ import { cleanup, render, screen } from '@testing-library/react'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
// Import real utility functions (pure functions, no side effects)
// Import mocked modules for manipulation
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { usePipelineInit } from './hooks'
import RagPipelineWrapper from './index'
import { processNodesWithoutDataSource } from './utils'
import { usePipelineInit } from '../hooks'
import RagPipelineWrapper from '../index'
import { processNodesWithoutDataSource } from '../utils'
// Mock: Context - need to control return values
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn(),
}))
// Mock: Hook with API calls
vi.mock('./hooks', () => ({
vi.mock('../hooks', () => ({
usePipelineInit: vi.fn(),
}))
// Mock: Store creator
vi.mock('./store', () => ({
vi.mock('../store', () => ({
createRagPipelineSliceSlice: vi.fn(() => ({})),
}))
// Mock: Utility with complex workflow dependencies (generateNewNode, etc.)
vi.mock('./utils', () => ({
vi.mock('../utils', () => ({
processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({
nodes,
viewport,
})),
}))
// Mock: Complex component with useParams, Toast, API calls
vi.mock('./components/conversion', () => ({
vi.mock('../components/conversion', () => ({
default: () => <div data-testid="conversion-component">Conversion Component</div>,
}))
// Mock: Complex component with many hooks and workflow dependencies
vi.mock('./components/rag-pipeline-main', () => ({
default: ({ nodes, edges, viewport }: any) => (
vi.mock('../components/rag-pipeline-main', () => ({
default: ({ nodes, edges, viewport }: { nodes?: unknown[], edges?: unknown[], viewport?: { zoom?: number } }) => (
<div data-testid="rag-pipeline-main">
<span data-testid="nodes-count">{nodes?.length ?? 0}</span>
<span data-testid="edges-count">{edges?.length ?? 0}</span>
@@ -50,35 +41,29 @@ vi.mock('./components/rag-pipeline-main', () => ({
),
}))
// Mock: Complex component with ReactFlow and many providers
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
// Mock: Context provider
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
// Type assertions for mocked functions
const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector)
const mockUsePipelineInit = vi.mocked(usePipelineInit)
const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource)
// Helper to mock selector with actual execution (increases function coverage)
// This executes the real selector function: s => s.dataset?.pipeline_id
const mockSelectorWithDataset = (pipelineId: string | null | undefined) => {
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => {
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null }
return selector(mockState)
})
}
// Test data factory
const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({
graph: {
nodes: [
@@ -157,7 +142,6 @@ describe('RagPipelineWrapper', () => {
describe('RagPipeline', () => {
beforeEach(() => {
// Default setup for RagPipeline tests - execute real selector function
mockSelectorWithDataset('pipeline-123')
})
@@ -167,7 +151,6 @@ describe('RagPipeline', () => {
render(<RagPipelineWrapper />)
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
@@ -240,8 +223,6 @@ describe('RagPipeline', () => {
render(<RagPipelineWrapper />)
// initialNodes is a real function - verify nodes are rendered
// The real initialNodes processes nodes and adds position data
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
@@ -251,7 +232,6 @@ describe('RagPipeline', () => {
render(<RagPipelineWrapper />)
// initialEdges is a real function - verify component renders with edges
expect(screen.getByTestId('edges-count').textContent).toBe('1')
})
@@ -269,7 +249,6 @@ describe('RagPipeline', () => {
render(<RagPipelineWrapper />)
// When data is undefined, Loading is shown, processNodesWithoutDataSource is not called
expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled()
})
@@ -279,13 +258,10 @@ describe('RagPipeline', () => {
const { rerender } = render(<RagPipelineWrapper />)
// Clear mock call count after initial render
mockProcessNodesWithoutDataSource.mockClear()
// Rerender with same data reference (no change to mockUsePipelineInit)
rerender(<RagPipelineWrapper />)
// processNodesWithoutDataSource should not be called again due to useMemo
// Note: React strict mode may cause double render, so we check it's not excessive
expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1)
})
@@ -327,7 +303,7 @@ describe('RagPipeline', () => {
graph: {
nodes: [],
edges: [],
viewport: undefined as any,
viewport: undefined as never,
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
@@ -342,7 +318,7 @@ describe('RagPipeline', () => {
graph: {
nodes: [],
edges: [],
viewport: null as any,
viewport: null as never,
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
@@ -438,7 +414,7 @@ describe('processNodesWithoutDataSource utility integration', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
mockProcessNodesWithoutDataSource.mockReturnValue({
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any,
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as unknown as ReturnType<typeof processNodesWithoutDataSource>['nodes'],
viewport: { x: 0, y: 0, zoom: 2 },
})
@@ -467,14 +443,11 @@ describe('Conditional Rendering Flow', () => {
it('should transition from loading to loaded state', () => {
mockSelectorWithDataset('pipeline-123')
// Start with loading state
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
const { rerender } = render(<RagPipelineWrapper />)
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
// Transition to loaded state
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
rerender(<RagPipelineWrapper />)
@@ -483,7 +456,6 @@ describe('Conditional Rendering Flow', () => {
})
it('should switch from Conversion to Pipeline when pipelineId becomes available', () => {
// Start without pipelineId
mockSelectorWithDataset(null)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
@@ -491,13 +463,11 @@ describe('Conditional Rendering Flow', () => {
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
// PipelineId becomes available
mockSelectorWithDataset('new-pipeline-id')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
rerender(<RagPipelineWrapper />)
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
@@ -510,21 +480,18 @@ describe('Error Handling', () => {
it('should throw when graph nodes is null', () => {
const mockData = {
graph: {
nodes: null as any,
edges: null as any,
nodes: null,
edges: null,
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'test',
updated_at: 123,
} as FetchWorkflowDraftResponse
} as unknown as FetchWorkflowDraftResponse
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Real initialNodes will throw when nodes is null
// This documents the component's current behavior - it requires valid nodes array
expect(() => render(<RagPipelineWrapper />)).toThrow()
consoleSpy.mockRestore()
@@ -538,11 +505,8 @@ describe('Error Handling', () => {
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// When graph is undefined, component throws because data.graph.nodes is accessed
// This documents the component's current behavior - it requires graph to be present
expect(() => render(<RagPipelineWrapper />)).toThrow()
consoleSpy.mockRestore()

View File

@@ -0,0 +1,182 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Conversion from '../conversion'
const mockConvert = vi.fn()
const mockInvalidDatasetDetail = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
}))
vi.mock('@/service/use-pipeline', () => ({
useConvertDatasetToPipeline: () => ({
mutateAsync: mockConvert,
isPending: false,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset-detail'],
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidDatasetDetail,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, ...props }: Record<string, unknown>) => (
<button onClick={onClick as () => void} {...props}>{children as string}</button>
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
title,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
}) =>
isShow
? (
<div data-testid="confirm-modal">
<span>{title}</span>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('../screenshot', () => ({
default: () => <div data-testid="screenshot" />,
}))
describe('Conversion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should render conversion title and description', () => {
render(<Conversion />)
expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.descriptionChunk1')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.descriptionChunk2')).toBeInTheDocument()
})
it('should render convert button', () => {
render(<Conversion />)
expect(screen.getByText('datasetPipeline.operations.convert')).toBeInTheDocument()
})
it('should render warning text', () => {
render(<Conversion />)
expect(screen.getByText('datasetPipeline.conversion.warning')).toBeInTheDocument()
})
it('should render screenshot component', () => {
render(<Conversion />)
expect(screen.getByTestId('screenshot')).toBeInTheDocument()
})
it('should show confirm modal when convert button clicked', () => {
render(<Conversion />)
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
})
it('should hide confirm modal when cancel is clicked', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
})
it('should call convert when confirm is clicked', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
})
it('should handle successful conversion', async () => {
const Toast = await import('@/app/components/base/toast')
mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => {
opts.onSuccess({ status: 'success' })
})
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
expect(mockInvalidDatasetDetail).toHaveBeenCalled()
})
it('should handle failed conversion', async () => {
const Toast = await import('@/app/components/base/toast')
mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => {
opts.onSuccess({ status: 'failed' })
})
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
it('should handle conversion error', async () => {
const Toast = await import('@/app/components/base/toast')
mockConvert.mockImplementation((_id: string, opts: { onError: () => void }) => {
opts.onError()
})
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})

View File

@@ -3,29 +3,19 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
// ============================================================================
// Import Components After Mocks Setup
// ============================================================================
import Conversion from '../conversion'
import RagPipelinePanel from '../panel'
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal'
import PublishToast from '../publish-toast'
import RagPipelineChildren from '../rag-pipeline-children'
import PipelineScreenShot from '../screenshot'
import Conversion from './conversion'
import RagPipelinePanel from './panel'
import PublishAsKnowledgePipelineModal from './publish-as-knowledge-pipeline-modal'
import PublishToast from './publish-toast'
import RagPipelineChildren from './rag-pipeline-children'
import PipelineScreenShot from './screenshot'
// ============================================================================
// Mock External Dependencies - All vi.mock calls must come before any imports
// ============================================================================
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
useRouter: () => ({ push: mockPush }),
}))
// Mock next/image
vi.mock('next/image', () => ({
default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => (
// eslint-disable-next-line next/no-img-element
@@ -33,7 +23,6 @@ vi.mock('next/image', () => ({
),
}))
// Mock next/dynamic
vi.mock('next/dynamic', () => ({
default: (importFn: () => Promise<{ default: React.ComponentType<unknown> }>, options?: { ssr?: boolean }) => {
const DynamicComponent = ({ children, ...props }: PropsWithChildren) => {
@@ -44,7 +33,6 @@ vi.mock('next/dynamic', () => ({
},
}))
// Mock workflow store - using controllable state
let mockShowImportDSLModal = false
const mockSetShowImportDSLModal = vi.fn((value: boolean) => {
mockShowImportDSLModal = value
@@ -112,7 +100,6 @@ vi.mock('@/app/components/workflow/store', () => {
}
})
// Mock workflow hooks - extract mock functions for assertions using vi.hoisted
const {
mockHandlePaneContextmenuCancel,
mockExportCheck,
@@ -148,8 +135,7 @@ vi.mock('@/app/components/workflow/hooks', () => {
}
})
// Mock rag-pipeline hooks
vi.mock('../hooks', () => ({
vi.mock('../../hooks', () => ({
useAvailableNodesMetaData: () => ({}),
useDSL: () => ({
exportCheck: mockExportCheck,
@@ -178,18 +164,15 @@ vi.mock('../hooks', () => ({
}),
}))
// Mock rag-pipeline search hook
vi.mock('../hooks/use-rag-pipeline-search', () => ({
vi.mock('../../hooks/use-rag-pipeline-search', () => ({
useRagPipelineSearch: vi.fn(),
}))
// Mock configs-map hook
vi.mock('../hooks/use-configs-map', () => ({
vi.mock('../../hooks/use-configs-map', () => ({
useConfigsMap: () => ({}),
}))
// Mock inspect-vars-crud hook
vi.mock('../hooks/use-inspect-vars-crud', () => ({
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
useInspectVarsCrud: () => ({
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
@@ -208,14 +191,12 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({
}),
}))
// Mock workflow hooks for fetch-workflow-inspect-vars
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: vi.fn(),
}),
}))
// Mock service hooks - with controllable convert function
let mockConvertFn = vi.fn()
let mockIsPending = false
vi.mock('@/service/use-pipeline', () => ({
@@ -253,7 +234,6 @@ vi.mock('@/service/workflow', () => ({
}),
}))
// Mock event emitter context - with controllable subscription
let mockEventSubscriptionCallback: ((v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null
const mockUseSubscription = vi.fn((callback: (v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => {
mockEventSubscriptionCallback = callback
@@ -267,7 +247,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
// Mock toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
@@ -280,33 +259,28 @@ vi.mock('@/app/components/base/toast', () => ({
},
}))
// Mock useTheme hook
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
}),
}))
// Mock basePath
vi.mock('@/utils/var', () => ({
basePath: '/public',
}))
// Mock provider context
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => createMockProviderContextValue(),
useProviderContextSelector: <T,>(selector: (state: ReturnType<typeof createMockProviderContextValue>) => T): T =>
selector(createMockProviderContextValue()),
}))
// Mock WorkflowWithInnerContext
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({ children }: PropsWithChildren) => (
<div data-testid="workflow-inner-context">{children}</div>
),
}))
// Mock workflow panel
vi.mock('@/app/components/workflow/panel', () => ({
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
<div data-testid="workflow-panel">
@@ -316,19 +290,16 @@ vi.mock('@/app/components/workflow/panel', () => ({
),
}))
// Mock PluginDependency
vi.mock('../../workflow/plugin-dependency', () => ({
vi.mock('../../../workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency" />,
}))
// Mock plugin-dependency hooks
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: vi.fn().mockResolvedValue(undefined),
}),
}))
// Mock DSLExportConfirmModal
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => (
<div data-testid="dsl-export-confirm-modal">
@@ -339,13 +310,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
}))
// Mock workflow utils
vi.mock('@/app/components/workflow/utils', () => ({
initialNodes: vi.fn(nodes => nodes),
initialEdges: vi.fn(edges => edges),
@@ -353,7 +322,6 @@ vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyNameBySystem: (key: string) => key,
}))
// Mock Confirm component
vi.mock('@/app/components/base/confirm', () => ({
default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: {
title: string
@@ -381,7 +349,6 @@ vi.mock('@/app/components/base/confirm', () => ({
: null,
}))
// Mock Modal component
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow, onClose, className }: PropsWithChildren<{
isShow: boolean
@@ -396,7 +363,6 @@ vi.mock('@/app/components/base/modal', () => ({
: null,
}))
// Mock Input component
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange, placeholder }: {
value: string
@@ -412,7 +378,6 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
// Mock Textarea component
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, className }: {
value: string
@@ -430,7 +395,6 @@ vi.mock('@/app/components/base/textarea', () => ({
),
}))
// Mock AppIcon component
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, iconType, icon, background, imageUrl, className, size }: {
onClick?: () => void
@@ -454,7 +418,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
),
}))
// Mock AppIconPicker component
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void
@@ -478,7 +441,6 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
),
}))
// Mock Uploader component
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ file, updateFile, className, accept, displayName }: {
file?: File
@@ -504,25 +466,21 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
),
}))
// Mock use-context-selector
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({
notify: vi.fn(),
})),
}))
// Mock RagPipelineHeader
vi.mock('./rag-pipeline-header', () => ({
vi.mock('../rag-pipeline-header', () => ({
default: () => <div data-testid="rag-pipeline-header" />,
}))
// Mock PublishToast
vi.mock('./publish-toast', () => ({
vi.mock('../publish-toast', () => ({
default: () => <div data-testid="publish-toast" />,
}))
// Mock UpdateDSLModal for RagPipelineChildren tests
vi.mock('./update-dsl-modal', () => ({
vi.mock('../update-dsl-modal', () => ({
default: ({ onCancel, onBackup, onImport }: {
onCancel: () => void
onBackup: () => void
@@ -536,7 +494,6 @@ vi.mock('./update-dsl-modal', () => ({
),
}))
// Mock DSLExportConfirmModal for RagPipelineChildren tests
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ envList, onConfirm, onClose }: {
envList: EnvironmentVariable[]
@@ -555,18 +512,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
// ============================================================================
// Test Suites
// ============================================================================
describe('Conversion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render conversion component without crashing', () => {
render(<Conversion />)
@@ -600,9 +550,6 @@ describe('Conversion', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should show confirm modal when convert button is clicked', () => {
render(<Conversion />)
@@ -617,20 +564,15 @@ describe('Conversion', () => {
it('should hide confirm modal when cancel is clicked', () => {
render(<Conversion />)
// Open modal
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
// Cancel modal
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// API Callback Tests - covers lines 21-39
// --------------------------------------------------------------------------
describe('API Callbacks', () => {
beforeEach(() => {
mockConvertFn = vi.fn()
@@ -638,14 +580,12 @@ describe('Conversion', () => {
})
it('should call convert with datasetId and show success toast on success', async () => {
// Setup mock to capture and call onSuccess callback
mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => {
options.onSuccess({ status: 'success' })
})
render(<Conversion />)
// Open modal and confirm
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
@@ -690,7 +630,6 @@ describe('Conversion', () => {
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalled()
})
// Modal should still be visible since conversion failed
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
})
@@ -711,32 +650,23 @@ describe('Conversion', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Conversion is exported with React.memo
expect((Conversion as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
it('should use useCallback for handleConvert', () => {
const { rerender } = render(<Conversion />)
// Rerender should not cause issues with callback
rerender(<Conversion />)
expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle missing datasetId gracefully', () => {
render(<Conversion />)
// Component should render without crashing
expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument()
})
})
@@ -747,9 +677,6 @@ describe('PipelineScreenShot', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<PipelineScreenShot />)
@@ -770,14 +697,10 @@ describe('PipelineScreenShot', () => {
render(<PipelineScreenShot />)
const img = screen.getByTestId('mock-image')
// Default theme is 'light' from mock
expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
expect((PipelineScreenShot as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
@@ -790,9 +713,6 @@ describe('PublishToast', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Note: PublishToast is mocked, so we just verify the mock renders
@@ -802,12 +722,8 @@ describe('PublishToast', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be defined', () => {
// The real PublishToast is mocked, but we can verify the import
expect(PublishToast).toBeDefined()
})
})
@@ -826,9 +742,6 @@ describe('PublishAsKnowledgePipelineModal', () => {
onConfirm: mockOnConfirm,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render modal with title', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
@@ -863,9 +776,6 @@ describe('PublishAsKnowledgePipelineModal', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update name when input changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
@@ -906,11 +816,9 @@ describe('PublishAsKnowledgePipelineModal', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Update values
fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } })
fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } })
// Click publish
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
expect(mockOnConfirm).toHaveBeenCalledWith(
@@ -931,52 +839,39 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update icon when emoji is selected', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Open picker
fireEvent.click(screen.getByTestId('app-icon'))
// Select emoji
fireEvent.click(screen.getByTestId('select-emoji'))
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should update icon when image is selected', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Open picker
fireEvent.click(screen.getByTestId('app-icon'))
// Select image
fireEvent.click(screen.getByTestId('select-image'))
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should close picker and restore icon when picker is closed', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Open picker
fireEvent.click(screen.getByTestId('app-icon'))
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
// Close picker
fireEvent.click(screen.getByTestId('close-picker'))
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Validation Tests
// --------------------------------------------------------------------------
describe('Props Validation', () => {
it('should disable publish button when name is empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Clear the name
fireEvent.change(screen.getByTestId('input'), { target: { value: '' } })
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
@@ -986,7 +881,6 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should disable publish button when name is only whitespace', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Set whitespace-only name
fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } })
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
@@ -1009,14 +903,10 @@ describe('PublishAsKnowledgePipelineModal', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should use useCallback for handleSelectIcon', () => {
const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
// Rerender should not cause issues
rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
})
@@ -1028,9 +918,6 @@ describe('RagPipelinePanel', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render panel component without crashing', () => {
render(<RagPipelinePanel />)
@@ -1046,9 +933,6 @@ describe('RagPipelinePanel', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped with memo', () => {
expect((RagPipelinePanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
@@ -1063,9 +947,6 @@ describe('RagPipelineChildren', () => {
mockEventSubscriptionCallback = null
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<RagPipelineChildren />)
@@ -1090,9 +971,6 @@ describe('RagPipelineChildren', () => {
})
})
// --------------------------------------------------------------------------
// Event Subscription Tests - covers lines 37-40
// --------------------------------------------------------------------------
describe('Event Subscription', () => {
it('should subscribe to event emitter', () => {
render(<RagPipelineChildren />)
@@ -1103,12 +981,10 @@ describe('RagPipelineChildren', () => {
it('should handle DSL_EXPORT_CHECK event and set secretEnvList', async () => {
render(<RagPipelineChildren />)
// Simulate DSL_EXPORT_CHECK event
const mockEnvVariables: EnvironmentVariable[] = [
{ id: '1', name: 'SECRET_KEY', value: 'test-secret', value_type: 'secret' as const, description: '' },
]
// Trigger the subscription callback
if (mockEventSubscriptionCallback) {
mockEventSubscriptionCallback({
type: 'DSL_EXPORT_CHECK',
@@ -1116,7 +992,6 @@ describe('RagPipelineChildren', () => {
})
}
// DSLExportConfirmModal should be rendered
await waitFor(() => {
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
})
@@ -1125,7 +1000,6 @@ describe('RagPipelineChildren', () => {
it('should not show DSLExportConfirmModal for non-DSL_EXPORT_CHECK events', () => {
render(<RagPipelineChildren />)
// Trigger a different event type
if (mockEventSubscriptionCallback) {
mockEventSubscriptionCallback({
type: 'OTHER_EVENT',
@@ -1136,9 +1010,6 @@ describe('RagPipelineChildren', () => {
})
})
// --------------------------------------------------------------------------
// UpdateDSLModal Handlers Tests - covers lines 48-51
// --------------------------------------------------------------------------
describe('UpdateDSLModal Handlers', () => {
beforeEach(() => {
mockShowImportDSLModal = true
@@ -1168,14 +1039,10 @@ describe('RagPipelineChildren', () => {
})
})
// --------------------------------------------------------------------------
// DSLExportConfirmModal Tests - covers lines 55-60
// --------------------------------------------------------------------------
describe('DSLExportConfirmModal', () => {
it('should render DSLExportConfirmModal when secretEnvList has items', async () => {
render(<RagPipelineChildren />)
// Simulate DSL_EXPORT_CHECK event with secrets
const mockEnvVariables: EnvironmentVariable[] = [
{ id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' },
]
@@ -1195,7 +1062,6 @@ describe('RagPipelineChildren', () => {
it('should close DSLExportConfirmModal when onClose is triggered', async () => {
render(<RagPipelineChildren />)
// First show the modal
const mockEnvVariables: EnvironmentVariable[] = [
{ id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' },
]
@@ -1211,7 +1077,6 @@ describe('RagPipelineChildren', () => {
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
})
// Close the modal
fireEvent.click(screen.getByTestId('dsl-export-close'))
await waitFor(() => {
@@ -1222,7 +1087,6 @@ describe('RagPipelineChildren', () => {
it('should call handleExportDSL when onConfirm is triggered', async () => {
render(<RagPipelineChildren />)
// Show the modal
const mockEnvVariables: EnvironmentVariable[] = [
{ id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' },
]
@@ -1238,16 +1102,12 @@ describe('RagPipelineChildren', () => {
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
})
// Confirm export
fireEvent.click(screen.getByTestId('dsl-export-confirm'))
expect(mockHandleExportDSL).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped with memo', () => {
expect((RagPipelineChildren as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
@@ -1255,10 +1115,6 @@ describe('RagPipelineChildren', () => {
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1276,17 +1132,13 @@ describe('Integration Tests', () => {
/>,
)
// Update name
fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } })
// Add description
fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } })
// Change icon
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-emoji'))
// Publish
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
await waitFor(() => {
@@ -1304,10 +1156,6 @@ describe('Integration Tests', () => {
})
})
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1322,7 +1170,6 @@ describe('Edge Cases', () => {
/>,
)
// Clear the name
const input = screen.getByTestId('input')
fireEvent.change(input, { target: { value: '' } })
expect(input).toHaveValue('')
@@ -1360,10 +1207,6 @@ describe('Edge Cases', () => {
})
})
// ============================================================================
// Accessibility Tests
// ============================================================================
describe('Accessibility', () => {
describe('Conversion', () => {
it('should have accessible button', () => {

View File

@@ -0,0 +1,244 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal'
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
knowledgeName: 'Test Pipeline',
knowledgeIcon: {
icon_type: 'emoji',
icon: '🔧',
icon_background: '#fff',
icon_url: '',
},
}),
}),
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) =>
isShow ? <div data-testid="modal">{children}</div> : null,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, ...props }: Record<string, unknown>) => (
<button onClick={onClick as () => void} disabled={disabled as boolean} {...props}>
{children as string}
</button>
),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
<input
data-testid="name-input"
value={value as string}
onChange={onChange as () => void}
{...props}
/>
),
}))
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
<textarea
data-testid="description-textarea"
value={value as string}
onChange={onChange as () => void}
{...props}
/>
),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<div data-testid="app-icon" onClick={onClick} />
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (item: { type: string, icon: string, background: string, url: string }) => void, onClose: () => void }) => (
<div data-testid="icon-picker">
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#eee', url: '' })}>
Select Emoji
</button>
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', icon: '', background: '', url: 'http://img.png' })}>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>
Close
</button>
</div>
),
}))
vi.mock('es-toolkit/function', () => ({
noop: () => {},
}))
describe('PublishAsKnowledgePipelineModal', () => {
const mockOnCancel = vi.fn()
const mockOnConfirm = vi.fn().mockResolvedValue(undefined)
const defaultProps = {
onCancel: mockOnCancel,
onConfirm: mockOnConfirm,
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should render modal with title', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
})
it('should initialize with knowledgeName from store', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const nameInput = screen.getByTestId('name-input') as HTMLInputElement
expect(nameInput.value).toBe('Test Pipeline')
})
it('should initialize description as empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByTestId('description-textarea') as HTMLTextAreaElement
expect(textarea.value).toBe('')
})
it('should call onCancel when close button clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('publish-modal-close-btn'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when cancel button clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onConfirm with name, icon, and description when confirm clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByText('workflow.common.publish'))
expect(mockOnConfirm).toHaveBeenCalledWith(
'Test Pipeline',
expect.objectContaining({ icon_type: 'emoji', icon: '🔧' }),
'',
)
})
it('should update pipeline name when input changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const nameInput = screen.getByTestId('name-input')
fireEvent.change(nameInput, { target: { value: 'New Name' } })
expect((nameInput as HTMLInputElement).value).toBe('New Name')
})
it('should update description when textarea changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByTestId('description-textarea')
fireEvent.change(textarea, { target: { value: 'My description' } })
expect((textarea as HTMLTextAreaElement).value).toBe('My description')
})
it('should disable confirm button when name is empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const nameInput = screen.getByTestId('name-input')
fireEvent.change(nameInput, { target: { value: '' } })
const confirmBtn = screen.getByText('workflow.common.publish')
expect(confirmBtn).toBeDisabled()
})
it('should disable confirm button when confirmDisabled is true', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} confirmDisabled />)
const confirmBtn = screen.getByText('workflow.common.publish')
expect(confirmBtn).toBeDisabled()
})
it('should not call onConfirm when confirmDisabled is true', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} confirmDisabled />)
fireEvent.click(screen.getByText('workflow.common.publish'))
expect(mockOnConfirm).not.toHaveBeenCalled()
})
it('should show icon picker when app icon clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('app-icon'))
expect(screen.getByTestId('icon-picker')).toBeInTheDocument()
})
it('should update icon when emoji is selected', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-emoji'))
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
})
it('should update icon when image is selected', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-image'))
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
})
it('should close icon picker when close is clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('close-picker'))
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
})
it('should trim name and description before submitting', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const nameInput = screen.getByTestId('name-input')
fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } })
const textarea = screen.getByTestId('description-textarea')
fireEvent.change(textarea, { target: { value: ' Some desc ' } })
fireEvent.click(screen.getByText('workflow.common.publish'))
expect(mockOnConfirm).toHaveBeenCalledWith(
'Trimmed Name',
expect.any(Object),
'Some desc',
)
})
})

View File

@@ -1,15 +1,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PublishToast from './publish-toast'
import PublishToast from '../publish-toast'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock workflow store with controllable state
let mockPublishedAt = 0
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
@@ -32,19 +24,19 @@ describe('PublishToast', () => {
mockPublishedAt = 0
render(<PublishToast />)
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
})
it('should render toast title', () => {
render(<PublishToast />)
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
})
it('should render toast description', () => {
render(<PublishToast />)
expect(screen.getByText('publishToast.desc')).toBeInTheDocument()
expect(screen.getByText('pipeline.publishToast.desc')).toBeInTheDocument()
})
it('should not render when publishedAt is set', () => {
@@ -57,14 +49,13 @@ describe('PublishToast', () => {
it('should have correct positioning classes', () => {
render(<PublishToast />)
const container = screen.getByText('publishToast.title').closest('.absolute')
const container = screen.getByText('pipeline.publishToast.title').closest('.absolute')
expect(container).toHaveClass('bottom-[45px]', 'left-0', 'right-0', 'z-10')
})
it('should render info icon', () => {
const { container } = render(<PublishToast />)
// The RiInformation2Fill icon should be rendered
const iconContainer = container.querySelector('.text-text-accent')
expect(iconContainer).toBeInTheDocument()
})
@@ -72,7 +63,6 @@ describe('PublishToast', () => {
it('should render close button', () => {
const { container } = render(<PublishToast />)
// The close button is a div with cursor-pointer, not a semantic button
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
})
@@ -82,25 +72,23 @@ describe('PublishToast', () => {
it('should hide toast when close button is clicked', () => {
const { container } = render(<PublishToast />)
// The close button is a div with cursor-pointer, not a semantic button
const closeButton = container.querySelector('.cursor-pointer')
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
fireEvent.click(closeButton!)
expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
})
it('should remain hidden after close button is clicked', () => {
const { container, rerender } = render(<PublishToast />)
// The close button is a div with cursor-pointer, not a semantic button
const closeButton = container.querySelector('.cursor-pointer')
fireEvent.click(closeButton!)
rerender(<PublishToast />)
expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
})
})
@@ -115,14 +103,14 @@ describe('PublishToast', () => {
it('should have correct toast width', () => {
render(<PublishToast />)
const toastContainer = screen.getByText('publishToast.title').closest('.w-\\[420px\\]')
const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.w-\\[420px\\]')
expect(toastContainer).toBeInTheDocument()
})
it('should have rounded border', () => {
render(<PublishToast />)
const toastContainer = screen.getByText('publishToast.title').closest('.rounded-xl')
const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.rounded-xl')
expect(toastContainer).toBeInTheDocument()
})
})

View File

@@ -2,10 +2,9 @@ import type { PropsWithChildren } from 'react'
import type { Edge, Node, Viewport } from 'reactflow'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import RagPipelineMain from './rag-pipeline-main'
import RagPipelineMain from '../rag-pipeline-main'
// Mock hooks from ../hooks
vi.mock('../hooks', () => ({
vi.mock('../../hooks', () => ({
useAvailableNodesMetaData: () => ({ nodes: [], nodesMap: {} }),
useDSL: () => ({
exportCheck: vi.fn(),
@@ -34,8 +33,7 @@ vi.mock('../hooks', () => ({
}),
}))
// Mock useConfigsMap
vi.mock('../hooks/use-configs-map', () => ({
vi.mock('../../hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'test-flow-id',
flowType: 'ragPipeline',
@@ -43,8 +41,7 @@ vi.mock('../hooks/use-configs-map', () => ({
}),
}))
// Mock useInspectVarsCrud
vi.mock('../hooks/use-inspect-vars-crud', () => ({
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
useInspectVarsCrud: () => ({
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
@@ -63,7 +60,6 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({
}),
}))
// Mock workflow store
const mockSetRagPipelineVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
@@ -75,14 +71,12 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
// Mock workflow hooks
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: vi.fn(),
}),
}))
// Mock WorkflowWithInnerContext
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({ children, onWorkflowDataUpdate }: PropsWithChildren<{ onWorkflowDataUpdate?: (payload: unknown) => void }>) => (
<div data-testid="workflow-inner-context">
@@ -108,8 +102,7 @@ vi.mock('@/app/components/workflow', () => ({
),
}))
// Mock RagPipelineChildren
vi.mock('./rag-pipeline-children', () => ({
vi.mock('../rag-pipeline-children', () => ({
default: () => <div data-testid="rag-pipeline-children">Children</div>,
}))
@@ -201,7 +194,6 @@ describe('RagPipelineMain', () => {
it('should use useNodesSyncDraft hook', () => {
render(<RagPipelineMain {...defaultProps} />)
// If the component renders, the hook was called successfully
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})

View File

@@ -1,8 +1,8 @@
import type { PropsWithChildren } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DSLImportStatus } from '@/models/app'
import UpdateDSLModal from './update-dsl-modal'
import UpdateDSLModal from '../update-dsl-modal'
class MockFileReader {
onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null
@@ -15,25 +15,15 @@ class MockFileReader {
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader)
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock use-context-selector
const mockNotify = vi.fn()
vi.mock('use-context-selector', () => ({
useContext: () => ({ notify: mockNotify }),
}))
// Mock toast context
vi.mock('@/app/components/base/toast', () => ({
ToastContext: { Provider: ({ children }: PropsWithChildren) => children },
}))
// Mock event emitter
const mockEmit = vi.fn()
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
@@ -41,7 +31,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
// Mock workflow store
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
@@ -50,13 +39,11 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
// Mock workflow utils
vi.mock('@/app/components/workflow/utils', () => ({
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}))
// Mock plugin dependencies
const mockHandleCheckPluginDependencies = vi.fn()
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
@@ -64,7 +51,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
}),
}))
// Mock pipeline service
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
@@ -72,7 +58,6 @@ vi.mock('@/service/use-pipeline', () => ({
useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }),
}))
// Mock workflow service
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
@@ -81,7 +66,6 @@ vi.mock('@/service/workflow', () => ({
}),
}))
// Mock Uploader
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ updateFile }: { updateFile: (file?: File) => void }) => (
<div data-testid="uploader">
@@ -103,7 +87,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
),
}))
// Mock Button
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, className, variant, loading }: {
children: React.ReactNode
@@ -125,7 +108,6 @@ vi.mock('@/app/components/base/button', () => ({
),
}))
// Mock Modal
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow, _onClose, className }: PropsWithChildren<{
isShow: boolean
@@ -140,16 +122,10 @@ vi.mock('@/app/components/base/modal', () => ({
: null,
}))
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('UpdateDSLModal', () => {
const mockOnCancel = vi.fn()
const mockOnBackup = vi.fn()
@@ -181,15 +157,13 @@ describe('UpdateDSLModal', () => {
it('should render title', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The component uses t('common.importDSL', { ns: 'workflow' }) which returns 'common.importDSL'
expect(screen.getByText('common.importDSL')).toBeInTheDocument()
expect(screen.getByText('workflow.common.importDSL')).toBeInTheDocument()
})
it('should render warning tip', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The component uses t('common.importDSLTip', { ns: 'workflow' })
expect(screen.getByText('common.importDSLTip')).toBeInTheDocument()
expect(screen.getByText('workflow.common.importDSLTip')).toBeInTheDocument()
})
it('should render uploader', () => {
@@ -201,29 +175,25 @@ describe('UpdateDSLModal', () => {
it('should render backup button', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The component uses t('common.backupCurrentDraft', { ns: 'workflow' })
expect(screen.getByText('common.backupCurrentDraft')).toBeInTheDocument()
expect(screen.getByText('workflow.common.backupCurrentDraft')).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The component uses t('newApp.Cancel', { ns: 'app' })
expect(screen.getByText('newApp.Cancel')).toBeInTheDocument()
expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument()
})
it('should render import button', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The component uses t('common.overwriteAndImport', { ns: 'workflow' })
expect(screen.getByText('common.overwriteAndImport')).toBeInTheDocument()
expect(screen.getByText('workflow.common.overwriteAndImport')).toBeInTheDocument()
})
it('should render choose DSL section', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The component uses t('common.chooseDSL', { ns: 'workflow' })
expect(screen.getByText('common.chooseDSL')).toBeInTheDocument()
expect(screen.getByText('workflow.common.chooseDSL')).toBeInTheDocument()
})
})
@@ -231,7 +201,7 @@ describe('UpdateDSLModal', () => {
it('should call onCancel when cancel button is clicked', () => {
render(<UpdateDSLModal {...defaultProps} />)
const cancelButton = screen.getByText('newApp.Cancel')
const cancelButton = screen.getByText('app.newApp.Cancel')
fireEvent.click(cancelButton)
expect(mockOnCancel).toHaveBeenCalled()
@@ -240,7 +210,7 @@ describe('UpdateDSLModal', () => {
it('should call onBackup when backup button is clicked', () => {
render(<UpdateDSLModal {...defaultProps} />)
const backupButton = screen.getByText('common.backupCurrentDraft')
const backupButton = screen.getByText('workflow.common.backupCurrentDraft')
fireEvent.click(backupButton)
expect(mockOnBackup).toHaveBeenCalled()
@@ -254,7 +224,6 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
// File should be processed
await waitFor(() => {
expect(screen.getByTestId('uploader')).toBeInTheDocument()
})
@@ -266,14 +235,12 @@ describe('UpdateDSLModal', () => {
const clearButton = screen.getByTestId('clear-file')
fireEvent.click(clearButton)
// File should be cleared
expect(screen.getByTestId('uploader')).toBeInTheDocument()
})
it('should call onCancel when close icon is clicked', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The close icon is in a div with onClick={onCancel}
const closeIconContainer = document.querySelector('.cursor-pointer')
if (closeIconContainer) {
fireEvent.click(closeIconContainer)
@@ -286,7 +253,7 @@ describe('UpdateDSLModal', () => {
it('should show import button disabled when no file is selected', () => {
render(<UpdateDSLModal {...defaultProps} />)
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).toBeDisabled()
})
@@ -299,7 +266,7 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
})
@@ -307,22 +274,20 @@ describe('UpdateDSLModal', () => {
it('should disable import button after file is cleared', async () => {
render(<UpdateDSLModal {...defaultProps} />)
// First select a file
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
// Clear the file
const clearButton = screen.getByTestId('clear-file')
fireEvent.click(clearButton)
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).toBeDisabled()
})
})
@@ -349,15 +314,14 @@ describe('UpdateDSLModal', () => {
it('should render import button with warning variant', () => {
render(<UpdateDSLModal {...defaultProps} />)
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).toHaveAttribute('data-variant', 'warning')
})
it('should render backup button with secondary variant', () => {
render(<UpdateDSLModal {...defaultProps} />)
// The backup button text is inside a nested div, so we need to find the closest button
const backupButtonText = screen.getByText('common.backupCurrentDraft')
const backupButtonText = screen.getByText('workflow.common.backupCurrentDraft')
const backupButton = backupButtonText.closest('button')
expect(backupButton).toHaveAttribute('data-variant', 'secondary')
})
@@ -367,22 +331,18 @@ describe('UpdateDSLModal', () => {
it('should call importDSL when import button is clicked with file content', async () => {
render(<UpdateDSLModal {...defaultProps} />)
// Select a file
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
// Wait for FileReader to process
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
// Click import button
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
// Wait for import to be called
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalled()
})
@@ -397,17 +357,16 @@ describe('UpdateDSLModal', () => {
render(<UpdateDSLModal {...defaultProps} />)
// Select a file and click import
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -431,11 +390,11 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -457,11 +416,11 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
}, { timeout: 1000 })
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -483,11 +442,11 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -511,11 +470,11 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -538,13 +497,12 @@ describe('UpdateDSLModal', () => {
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
// Wait for FileReader to process and button to be enabled
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -563,13 +521,12 @@ describe('UpdateDSLModal', () => {
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
fireEvent.change(fileInput, { target: { files: [file] } })
// Wait for FileReader to complete and button to be enabled
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -593,16 +550,15 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
// Flush the FileReader microtask to ensure fileContent is set
await act(async () => {
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -624,11 +580,11 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
@@ -654,23 +610,20 @@ describe('UpdateDSLModal', () => {
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } })
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.click(importButton)
// Flush the promise resolution from mockImportDSL
await Promise.resolve()
// Advance past the 300ms setTimeout in the component
await vi.advanceTimersByTimeAsync(350)
})
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
vi.useRealTimers()
@@ -692,14 +645,13 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
// Wait for error modal with version info
await waitFor(() => {
expect(screen.getByText('1.0.0')).toBeInTheDocument()
expect(screen.getByText('2.0.0')).toBeInTheDocument()
@@ -722,20 +674,18 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
// Wait for error modal
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
// Find and click cancel button in error modal - it should be the one with secondary variant
const cancelButtons = screen.getAllByText('newApp.Cancel')
const cancelButtons = screen.getAllByText('app.newApp.Cancel')
const errorModalCancelButton = cancelButtons.find(btn =>
btn.getAttribute('data-variant') === 'secondary',
)
@@ -743,9 +693,8 @@ describe('UpdateDSLModal', () => {
fireEvent.click(errorModalCancelButton)
}
// Modal should be closed
await waitFor(() => {
expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
})
})
@@ -772,27 +721,23 @@ describe('UpdateDSLModal', () => {
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } })
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.click(importButton)
// Flush the promise resolution from mockImportDSL
await Promise.resolve()
// Advance past the 300ms setTimeout in the component
await vi.advanceTimersByTimeAsync(350)
})
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
// Click confirm button
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -823,18 +768,18 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -865,18 +810,18 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -904,18 +849,18 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -946,18 +891,18 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -988,18 +933,18 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -1030,26 +975,23 @@ describe('UpdateDSLModal', () => {
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } })
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
await new Promise<void>(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
await act(async () => {
fireEvent.click(importButton)
// Flush the promise resolution from mockImportDSL
await Promise.resolve()
// Advance past the 300ms setTimeout in the component
await vi.advanceTimersByTimeAsync(350)
})
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
const confirmButton = screen.getByText('newApp.Confirm')
const confirmButton = screen.getByText('app.newApp.Confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
@@ -1075,25 +1017,21 @@ describe('UpdateDSLModal', () => {
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
})
const importButton = screen.getByText('common.overwriteAndImport')
const importButton = screen.getByText('workflow.common.overwriteAndImport')
fireEvent.click(importButton)
// Should show error modal even with undefined versions
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
}, { timeout: 1000 })
})
it('should not call importDSLConfirm when importId is not set', async () => {
// Render without triggering PENDING status first
render(<UpdateDSLModal {...defaultProps} />)
// importId is not set, so confirm should not be called
// This is hard to test directly, but we can verify by checking the confirm flow
expect(mockImportDSLConfirm).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,117 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import VersionMismatchModal from '../version-mismatch-modal'
describe('VersionMismatchModal', () => {
const mockOnClose = vi.fn()
const mockOnConfirm = vi.fn()
const defaultVersions = {
importedVersion: '0.8.0',
systemVersion: '1.0.0',
}
const defaultProps = {
isShow: true,
versions: defaultVersions,
onClose: mockOnClose,
onConfirm: mockOnConfirm,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render dialog when isShow is true', () => {
render(<VersionMismatchModal {...defaultProps} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should not render dialog when isShow is false', () => {
render(<VersionMismatchModal {...defaultProps} isShow={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render error title', () => {
render(<VersionMismatchModal {...defaultProps} />)
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
it('should render all error description parts', () => {
render(<VersionMismatchModal {...defaultProps} />)
expect(screen.getByText('app.newApp.appCreateDSLErrorPart1')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorPart2')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorPart3')).toBeInTheDocument()
expect(screen.getByText('app.newApp.appCreateDSLErrorPart4')).toBeInTheDocument()
})
it('should display imported and system version numbers', () => {
render(<VersionMismatchModal {...defaultProps} />)
expect(screen.getByText('0.8.0')).toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
})
it('should render cancel and confirm buttons', () => {
render(<VersionMismatchModal {...defaultProps} />)
expect(screen.getByRole('button', { name: /app\.newApp\.Cancel/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /app\.newApp\.Confirm/ })).toBeInTheDocument()
})
})
describe('user interactions', () => {
it('should call onClose when cancel button is clicked', () => {
render(<VersionMismatchModal {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Cancel/ }))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onConfirm when confirm button is clicked', () => {
render(<VersionMismatchModal {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Confirm/ }))
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
})
})
describe('button variants', () => {
it('should render cancel button with secondary variant', () => {
render(<VersionMismatchModal {...defaultProps} />)
const cancelBtn = screen.getByRole('button', { name: /app\.newApp\.Cancel/ })
expect(cancelBtn).toHaveClass('btn-secondary')
})
it('should render confirm button with primary destructive variant', () => {
render(<VersionMismatchModal {...defaultProps} />)
const confirmBtn = screen.getByRole('button', { name: /app\.newApp\.Confirm/ })
expect(confirmBtn).toHaveClass('btn-primary')
expect(confirmBtn).toHaveClass('btn-destructive')
})
})
describe('edge cases', () => {
it('should handle undefined versions gracefully', () => {
render(<VersionMismatchModal {...defaultProps} versions={undefined} />)
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
it('should handle empty version strings', () => {
const emptyVersions = { importedVersion: '', systemVersion: '' }
render(<VersionMismatchModal {...defaultProps} versions={emptyVersions} />)
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,212 @@
import type { ParentChildChunk } from '../types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import ChunkCard from '../chunk-card'
vi.mock('@/app/components/datasets/documents/detail/completed/common/dot', () => ({
default: () => <span data-testid="dot" />,
}))
vi.mock('@/app/components/datasets/documents/detail/completed/common/segment-index-tag', () => ({
default: ({ positionId, labelPrefix }: { positionId?: string | number, labelPrefix: string }) => (
<span data-testid="segment-tag">
{labelPrefix}
-
{positionId}
</span>
),
}))
vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({
default: ({ summary }: { summary: string }) => <span data-testid="summary">{summary}</span>,
}))
vi.mock('@/app/components/datasets/formatted-text/flavours/preview-slice', () => ({
PreviewSlice: ({ label, text }: { label: string, text: string }) => (
<span data-testid="preview-slice">
{label}
:
{' '}
{text}
</span>
),
}))
vi.mock('@/models/datasets', () => ({
ChunkingMode: {
text: 'text',
parentChild: 'parent-child',
qa: 'qa',
},
}))
vi.mock('@/utils/format', () => ({
formatNumber: (n: number) => String(n),
}))
vi.mock('../q-a-item', () => ({
default: ({ type, text }: { type: string, text: string }) => (
<span data-testid={`qa-${type}`}>{text}</span>
),
}))
vi.mock('../types', () => ({
QAItemType: {
Question: 'question',
Answer: 'answer',
},
}))
const makeParentChildContent = (overrides: Partial<ParentChildChunk> = {}): ParentChildChunk => ({
child_contents: ['Child'],
parent_content: '',
parent_summary: '',
parent_mode: 'paragraph',
...overrides,
})
describe('ChunkCard', () => {
describe('Text mode', () => {
it('should render text content', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content={{ content: 'Hello world', summary: 'Summary text' }}
positionId={1}
wordCount={42}
/>,
)
expect(screen.getByText('Hello world')).toBeInTheDocument()
})
it('should render segment index tag with Chunk prefix', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content={{ content: 'Test', summary: '' }}
positionId={5}
wordCount={10}
/>,
)
expect(screen.getByText('Chunk-5')).toBeInTheDocument()
})
it('should render word count', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content={{ content: 'Test', summary: '' }}
positionId={1}
wordCount={100}
/>,
)
expect(screen.getByText(/100/)).toBeInTheDocument()
})
it('should render summary when available', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content={{ content: 'Test', summary: 'A summary' }}
positionId={1}
wordCount={10}
/>,
)
expect(screen.getByTestId('summary')).toHaveTextContent('A summary')
})
})
describe('Parent-Child mode (paragraph)', () => {
it('should render child contents as preview slices', () => {
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={makeParentChildContent({
child_contents: ['Child 1', 'Child 2'],
parent_summary: 'Parent summary',
})}
positionId={3}
wordCount={50}
/>,
)
const slices = screen.getAllByTestId('preview-slice')
expect(slices).toHaveLength(2)
expect(slices[0]).toHaveTextContent('C-1: Child 1')
expect(slices[1]).toHaveTextContent('C-2: Child 2')
})
it('should render Parent-Chunk prefix for paragraph mode', () => {
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={makeParentChildContent()}
positionId={2}
wordCount={20}
/>,
)
expect(screen.getByText('Parent-Chunk-2')).toBeInTheDocument()
})
it('should render parent summary', () => {
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={makeParentChildContent({
child_contents: ['C1'],
parent_summary: 'Overview',
})}
positionId={1}
wordCount={10}
/>,
)
expect(screen.getByTestId('summary')).toHaveTextContent('Overview')
})
})
describe('Parent-Child mode (full-doc)', () => {
it('should hide segment tag in full-doc mode', () => {
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="full-doc"
content={makeParentChildContent({
child_contents: ['Full doc child'],
parent_mode: 'full-doc',
})}
positionId={1}
wordCount={300}
/>,
)
expect(screen.queryByTestId('segment-tag')).not.toBeInTheDocument()
})
})
describe('QA mode', () => {
it('should render question and answer items', () => {
render(
<ChunkCard
chunkType={ChunkingMode.qa}
content={{ question: 'What is X?', answer: 'X is Y' }}
positionId={1}
wordCount={15}
/>,
)
expect(screen.getByTestId('qa-question')).toHaveTextContent('What is X?')
expect(screen.getByTestId('qa-answer')).toHaveTextContent('X is Y')
})
})
})

View File

@@ -1,14 +1,10 @@
import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types'
import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from '../types'
import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkCard from './chunk-card'
import { ChunkCardList } from './index'
import QAItem from './q-a-item'
import { QAItemType } from './types'
// =============================================================================
// Test Data Factories
// =============================================================================
import ChunkCard from '../chunk-card'
import { ChunkCardList } from '../index'
import QAItem from '../q-a-item'
import { QAItemType } from '../types'
const createGeneralChunks = (overrides: GeneralChunks = []): GeneralChunks => {
if (overrides.length > 0)
@@ -56,99 +52,71 @@ const createQAChunks = (overrides: Partial<QAChunks> = {}): QAChunks => ({
...overrides,
})
// =============================================================================
// QAItem Component Tests
// =============================================================================
describe('QAItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Tests for basic rendering of QAItem component
describe('Rendering', () => {
it('should render question type with Q prefix', () => {
// Arrange & Act
render(<QAItem type={QAItemType.Question} text="What is this?" />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('What is this?')).toBeInTheDocument()
})
it('should render answer type with A prefix', () => {
// Arrange & Act
render(<QAItem type={QAItemType.Answer} text="This is the answer." />)
// Assert
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('This is the answer.')).toBeInTheDocument()
})
})
// Tests for different prop variations
describe('Props', () => {
it('should render with empty text', () => {
// Arrange & Act
render(<QAItem type={QAItemType.Question} text="" />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
})
it('should render with long text content', () => {
// Arrange
const longText = 'A'.repeat(1000)
// Act
render(<QAItem type={QAItemType.Answer} text={longText} />)
// Assert
expect(screen.getByText(longText)).toBeInTheDocument()
})
it('should render with special characters in text', () => {
// Arrange
const specialText = '<script>alert("xss")</script> & "quotes" \'apostrophe\''
// Act
render(<QAItem type={QAItemType.Question} text={specialText} />)
// Assert
expect(screen.getByText(specialText)).toBeInTheDocument()
})
})
// Tests for memoization behavior
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
// Arrange & Act
const { rerender } = render(<QAItem type={QAItemType.Question} text="Test" />)
// Assert - component should render consistently
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('Test')).toBeInTheDocument()
// Rerender with same props - should not cause issues
rerender(<QAItem type={QAItemType.Question} text="Test" />)
expect(screen.getByText('Q')).toBeInTheDocument()
})
})
})
// =============================================================================
// ChunkCard Component Tests
// =============================================================================
describe('ChunkCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Tests for basic rendering with different chunk types
describe('Rendering', () => {
it('should render text chunk type correctly', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -158,19 +126,16 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('This is the first chunk of text content.')).toBeInTheDocument()
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
})
it('should render QA chunk type with question and answer', () => {
// Arrange
const qaContent: QAChunk = {
question: 'What is React?',
answer: 'React is a JavaScript library.',
}
// Act
render(
<ChunkCard
chunkType={ChunkingMode.qa}
@@ -180,7 +145,6 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('What is React?')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
@@ -188,10 +152,8 @@ describe('ChunkCard', () => {
})
it('should render parent-child chunk type with child contents', () => {
// Arrange
const childContents = ['Child 1 content', 'Child 2 content']
// Act
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -202,7 +164,6 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Child 1 content')).toBeInTheDocument()
expect(screen.getByText('Child 2 content')).toBeInTheDocument()
expect(screen.getByText('C-1')).toBeInTheDocument()
@@ -210,10 +171,8 @@ describe('ChunkCard', () => {
})
})
// Tests for parent mode variations
describe('Parent Mode Variations', () => {
it('should show Parent-Chunk label prefix for paragraph mode', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -224,12 +183,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument()
})
it('should hide segment index tag for full-doc mode', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -240,13 +197,11 @@ describe('ChunkCard', () => {
/>,
)
// Assert - should not show Chunk or Parent-Chunk label
expect(screen.queryByText(/Chunk/)).not.toBeInTheDocument()
expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument()
})
it('should show Chunk label prefix for text mode', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -256,15 +211,12 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText(/Chunk-05/)).toBeInTheDocument()
})
})
// Tests for word count display
describe('Word Count Display', () => {
it('should display formatted word count', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -274,12 +226,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert - formatNumber(1234) returns '1,234'
expect(screen.getByText(/1,234/)).toBeInTheDocument()
})
it('should display word count with character translation key', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -289,12 +239,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert - translation key is returned as-is by mock
expect(screen.getByText(/100\s+(?:\S.*)?characters/)).toBeInTheDocument()
})
it('should not display word count info for full-doc mode', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -305,15 +253,12 @@ describe('ChunkCard', () => {
/>,
)
// Assert - the header with word count should be hidden
expect(screen.queryByText(/500/)).not.toBeInTheDocument()
})
})
// Tests for position ID variations
describe('Position ID', () => {
it('should handle numeric position ID', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -323,12 +268,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText(/Chunk-42/)).toBeInTheDocument()
})
it('should handle string position ID', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -338,12 +281,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText(/Chunk-99/)).toBeInTheDocument()
})
it('should pad single digit position ID', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -353,15 +294,12 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText(/Chunk-03/)).toBeInTheDocument()
})
})
// Tests for memoization dependencies
describe('Memoization', () => {
it('should update isFullDoc memo when parentMode changes', () => {
// Arrange
const { rerender } = render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -372,10 +310,8 @@ describe('ChunkCard', () => {
/>,
)
// Assert - paragraph mode shows label
expect(screen.getByText(/Parent-Chunk/)).toBeInTheDocument()
// Act - change to full-doc
rerender(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -386,12 +322,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert - full-doc mode hides label
expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument()
})
it('should update contentElement memo when content changes', () => {
// Arrange
const initialContent = { content: 'Initial content' }
const updatedContent = { content: 'Updated content' }
@@ -404,10 +338,8 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Initial content')).toBeInTheDocument()
// Act
rerender(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -417,13 +349,11 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Updated content')).toBeInTheDocument()
expect(screen.queryByText('Initial content')).not.toBeInTheDocument()
})
it('should update contentElement memo when chunkType changes', () => {
// Arrange
const textContent = { content: 'Text content' }
const { rerender } = render(
<ChunkCard
@@ -434,10 +364,8 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Text content')).toBeInTheDocument()
// Act - change to QA type
const qaContent: QAChunk = { question: 'Q?', answer: 'A.' }
rerender(
<ChunkCard
@@ -448,16 +376,13 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('Q?')).toBeInTheDocument()
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle empty child contents array', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.parentChild}
@@ -468,15 +393,12 @@ describe('ChunkCard', () => {
/>,
)
// Assert - should render without errors
expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument()
})
it('should handle QA chunk with empty strings', () => {
// Arrange
const emptyQA: QAChunk = { question: '', answer: '' }
// Act
render(
<ChunkCard
chunkType={ChunkingMode.qa}
@@ -486,17 +408,14 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should handle very long content', () => {
// Arrange
const longContent = 'A'.repeat(10000)
const longContentChunk = { content: longContent }
// Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -506,12 +425,10 @@ describe('ChunkCard', () => {
/>,
)
// Assert
expect(screen.getByText(longContent)).toBeInTheDocument()
})
it('should handle zero word count', () => {
// Arrange & Act
render(
<ChunkCard
chunkType={ChunkingMode.text}
@@ -521,28 +438,20 @@ describe('ChunkCard', () => {
/>,
)
// Assert - formatNumber returns falsy for 0, so it shows 0
expect(screen.getByText(/0\s+(?:\S.*)?characters/)).toBeInTheDocument()
})
})
})
// =============================================================================
// ChunkCardList Component Tests
// =============================================================================
describe('ChunkCardList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Tests for rendering with different chunk types
describe('Rendering', () => {
it('should render text chunks correctly', () => {
// Arrange
const chunks = createGeneralChunks()
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -550,17 +459,14 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText(chunks[0].content)).toBeInTheDocument()
expect(screen.getByText(chunks[1].content)).toBeInTheDocument()
expect(screen.getByText(chunks[2].content)).toBeInTheDocument()
})
it('should render parent-child chunks correctly', () => {
// Arrange
const chunks = createParentChildChunks()
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -569,17 +475,14 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - should render child contents from parent-child chunks
expect(screen.getByText('Child content 1')).toBeInTheDocument()
expect(screen.getByText('Child content 2')).toBeInTheDocument()
expect(screen.getByText('Another child 1')).toBeInTheDocument()
})
it('should render QA chunks correctly', () => {
// Arrange
const chunks = createQAChunks()
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.qa}
@@ -587,7 +490,6 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText('What is the answer to life?')).toBeInTheDocument()
expect(screen.getByText('The answer is 42.')).toBeInTheDocument()
expect(screen.getByText('How does this work?')).toBeInTheDocument()
@@ -595,16 +497,13 @@ describe('ChunkCardList', () => {
})
})
// Tests for chunkList memoization
describe('Memoization - chunkList', () => {
it('should extract chunks from GeneralChunks for text mode', () => {
// Arrange
const chunks: GeneralChunks = [
{ content: 'Chunk 1' },
{ content: 'Chunk 2' },
]
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -612,20 +511,17 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
expect(screen.getByText('Chunk 2')).toBeInTheDocument()
})
it('should extract parent_child_chunks from ParentChildChunks for parentChild mode', () => {
// Arrange
const chunks = createParentChildChunks({
parent_child_chunks: [
createParentChildChunk({ child_contents: ['Specific child'] }),
],
})
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -634,19 +530,16 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText('Specific child')).toBeInTheDocument()
})
it('should extract qa_chunks from QAChunks for qa mode', () => {
// Arrange
const chunks: QAChunks = {
qa_chunks: [
{ question: 'Specific Q', answer: 'Specific A' },
],
}
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.qa}
@@ -654,13 +547,11 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText('Specific Q')).toBeInTheDocument()
expect(screen.getByText('Specific A')).toBeInTheDocument()
})
it('should update chunkList when chunkInfo changes', () => {
// Arrange
const initialChunks = createGeneralChunks([{ content: 'Initial chunk' }])
const { rerender } = render(
@@ -670,10 +561,8 @@ describe('ChunkCardList', () => {
/>,
)
// Assert initial state
expect(screen.getByText('Initial chunk')).toBeInTheDocument()
// Act - update chunks
const updatedChunks = createGeneralChunks([{ content: 'Updated chunk' }])
rerender(
<ChunkCardList
@@ -682,19 +571,15 @@ describe('ChunkCardList', () => {
/>,
)
// Assert updated state
expect(screen.getByText('Updated chunk')).toBeInTheDocument()
expect(screen.queryByText('Initial chunk')).not.toBeInTheDocument()
})
})
// Tests for getWordCount function
describe('Word Count Calculation', () => {
it('should calculate word count for text chunks using string length', () => {
// Arrange - "Hello" has 5 characters
const chunks = createGeneralChunks([{ content: 'Hello' }])
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -702,12 +587,10 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - word count should be 5 (string length)
expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument()
})
it('should calculate word count for parent-child chunks using parent_content length', () => {
// Arrange - parent_content length determines word count
const chunks = createParentChildChunks({
parent_child_chunks: [
createParentChildChunk({
@@ -717,7 +600,6 @@ describe('ChunkCardList', () => {
],
})
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -726,19 +608,16 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - word count should be 6 (parent_content length)
expect(screen.getByText(/6\s+(?:\S.*)?characters/)).toBeInTheDocument()
})
it('should calculate word count for QA chunks using question + answer length', () => {
// Arrange - "Hi" (2) + "Bye" (3) = 5
const chunks: QAChunks = {
qa_chunks: [
{ question: 'Hi', answer: 'Bye' },
],
}
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.qa}
@@ -746,22 +625,18 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - word count should be 5 (question.length + answer.length)
expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument()
})
})
// Tests for position ID assignment
describe('Position ID', () => {
it('should assign 1-based position IDs to chunks', () => {
// Arrange
const chunks = createGeneralChunks([
{ content: 'First' },
{ content: 'Second' },
{ content: 'Third' },
])
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -769,20 +644,16 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - position IDs should be 1, 2, 3
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
expect(screen.getByText(/Chunk-02/)).toBeInTheDocument()
expect(screen.getByText(/Chunk-03/)).toBeInTheDocument()
})
})
// Tests for className prop
describe('Custom className', () => {
it('should apply custom className to container', () => {
// Arrange
const chunks = createGeneralChunks([{ content: 'Test' }])
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -791,15 +662,12 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
// Arrange
const chunks = createGeneralChunks([{ content: 'Test' }])
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -808,7 +676,6 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - should have both default and custom classes
expect(container.firstChild).toHaveClass('flex')
expect(container.firstChild).toHaveClass('w-full')
expect(container.firstChild).toHaveClass('flex-col')
@@ -816,10 +683,8 @@ describe('ChunkCardList', () => {
})
it('should render without className prop', () => {
// Arrange
const chunks = createGeneralChunks([{ content: 'Test' }])
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -827,19 +692,15 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - should have default classes
expect(container.firstChild).toHaveClass('flex')
expect(container.firstChild).toHaveClass('w-full')
})
})
// Tests for parentMode prop
describe('Parent Mode', () => {
it('should pass parentMode to ChunkCard for parent-child type', () => {
// Arrange
const chunks = createParentChildChunks()
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -848,15 +709,12 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - paragraph mode shows Parent-Chunk label
expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0)
})
it('should handle full-doc parentMode', () => {
// Arrange
const chunks = createParentChildChunks()
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -865,16 +723,13 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - full-doc mode hides chunk labels
expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument()
expect(screen.queryByText(/Chunk-/)).not.toBeInTheDocument()
})
it('should not use parentMode for text type', () => {
// Arrange
const chunks = createGeneralChunks([{ content: 'Text' }])
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -883,18 +738,14 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - should show Chunk label, not affected by parentMode
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle empty GeneralChunks array', () => {
// Arrange
const chunks: GeneralChunks = []
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -902,19 +753,16 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - should render empty container
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild?.childNodes.length).toBe(0)
})
it('should handle empty ParentChildChunks', () => {
// Arrange
const chunks: ParentChildChunks = {
parent_child_chunks: [],
parent_mode: 'paragraph',
}
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -923,18 +771,15 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild?.childNodes.length).toBe(0)
})
it('should handle empty QAChunks', () => {
// Arrange
const chunks: QAChunks = {
qa_chunks: [],
}
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.qa}
@@ -942,16 +787,13 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild?.childNodes.length).toBe(0)
})
it('should handle single item in chunks', () => {
// Arrange
const chunks = createGeneralChunks([{ content: 'Single chunk' }])
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -959,16 +801,13 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText('Single chunk')).toBeInTheDocument()
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
})
it('should handle large number of chunks', () => {
// Arrange
const chunks = Array.from({ length: 100 }, (_, i) => ({ content: `Chunk number ${i + 1}` }))
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -976,23 +815,19 @@ describe('ChunkCardList', () => {
/>,
)
// Assert
expect(screen.getByText('Chunk number 1')).toBeInTheDocument()
expect(screen.getByText('Chunk number 100')).toBeInTheDocument()
expect(screen.getByText(/Chunk-100/)).toBeInTheDocument()
})
})
// Tests for key uniqueness
describe('Key Generation', () => {
it('should generate unique keys for chunks', () => {
// Arrange - chunks with same content
const chunks = createGeneralChunks([
{ content: 'Same content' },
{ content: 'Same content' },
{ content: 'Same content' },
])
// Act
const { container } = render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -1000,33 +835,25 @@ describe('ChunkCardList', () => {
/>,
)
// Assert - all three should render (keys are based on chunkType-index)
const chunkCards = container.querySelectorAll('.bg-components-panel-bg')
expect(chunkCards.length).toBe(3)
})
})
})
// =============================================================================
// Integration Tests
// =============================================================================
describe('ChunkCardList Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Tests for complete workflow scenarios
describe('Complete Workflows', () => {
it('should render complete text chunking workflow', () => {
// Arrange
const textChunks = createGeneralChunks([
{ content: 'First paragraph of the document.' },
{ content: 'Second paragraph with more information.' },
{ content: 'Final paragraph concluding the content.' },
])
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.text}
@@ -1034,10 +861,8 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert
expect(screen.getByText('First paragraph of the document.')).toBeInTheDocument()
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
// "First paragraph of the document." = 32 characters
expect(screen.getByText(/32\s+(?:\S.*)?characters/)).toBeInTheDocument()
expect(screen.getByText('Second paragraph with more information.')).toBeInTheDocument()
@@ -1048,7 +873,6 @@ describe('ChunkCardList Integration', () => {
})
it('should render complete parent-child chunking workflow', () => {
// Arrange
const parentChildChunks = createParentChildChunks({
parent_child_chunks: [
{
@@ -1062,7 +886,6 @@ describe('ChunkCardList Integration', () => {
],
})
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -1071,7 +894,6 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert
expect(screen.getByText('React components are building blocks.')).toBeInTheDocument()
expect(screen.getByText('Lifecycle methods control component behavior.')).toBeInTheDocument()
expect(screen.getByText('C-1')).toBeInTheDocument()
@@ -1080,7 +902,6 @@ describe('ChunkCardList Integration', () => {
})
it('should render complete QA chunking workflow', () => {
// Arrange
const qaChunks = createQAChunks({
qa_chunks: [
{
@@ -1094,7 +915,6 @@ describe('ChunkCardList Integration', () => {
],
})
// Act
render(
<ChunkCardList
chunkType={ChunkingMode.qa}
@@ -1102,7 +922,6 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert
const qLabels = screen.getAllByText('Q')
const aLabels = screen.getAllByText('A')
expect(qLabels.length).toBe(2)
@@ -1115,10 +934,8 @@ describe('ChunkCardList Integration', () => {
})
})
// Tests for type switching scenarios
describe('Type Switching', () => {
it('should handle switching from text to QA type', () => {
// Arrange
const textChunks = createGeneralChunks([{ content: 'Text content' }])
const qaChunks = createQAChunks()
@@ -1129,10 +946,8 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert initial text state
expect(screen.getByText('Text content')).toBeInTheDocument()
// Act - switch to QA
rerender(
<ChunkCardList
chunkType={ChunkingMode.qa}
@@ -1140,13 +955,11 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert QA state
expect(screen.queryByText('Text content')).not.toBeInTheDocument()
expect(screen.getByText('What is the answer to life?')).toBeInTheDocument()
})
it('should handle switching from text to parent-child type', () => {
// Arrange
const textChunks = createGeneralChunks([{ content: 'Simple text' }])
const parentChildChunks = createParentChildChunks()
@@ -1157,11 +970,9 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert initial state
expect(screen.getByText('Simple text')).toBeInTheDocument()
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
// Act - switch to parent-child
rerender(
<ChunkCardList
chunkType={ChunkingMode.parentChild}
@@ -1170,9 +981,7 @@ describe('ChunkCardList Integration', () => {
/>,
)
// Assert parent-child state
expect(screen.queryByText('Simple text')).not.toBeInTheDocument()
// Multiple Parent-Chunk elements exist, so use getAllByText
expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0)
})
})

View File

@@ -1,13 +1,8 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import RagPipelinePanel from './index'
import RagPipelinePanel from '../index'
// ============================================================================
// Mock External Dependencies
// ============================================================================
// Mock reactflow to avoid zustand provider error
vi.mock('reactflow', () => ({
useNodes: () => [],
useStoreApi: () => ({
@@ -26,20 +21,12 @@ vi.mock('reactflow', () => ({
},
}))
// Use vi.hoisted to create variables that can be used in vi.mock
const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => {
let counter = 0
const mockInputFieldEditorProps = vi.fn()
const createMockComponent = () => {
const index = counter++
// Order matches the imports in index.tsx:
// 0: Record
// 1: TestRunPanel
// 2: InputFieldPanel
// 3: InputFieldEditorPanel
// 4: PreviewPanel
// 5: GlobalVariablePanel
switch (index) {
case 0:
return () => <div data-testid="record-panel">Record Panel</div>
@@ -69,14 +56,12 @@ const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => {
return { dynamicMocks: { createMockComponent }, mockInputFieldEditorProps }
})
// Mock next/dynamic
vi.mock('next/dynamic', () => ({
default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record<string, unknown>) => {
return dynamicMocks.createMockComponent()
},
}))
// Mock workflow store
let mockHistoryWorkflowData: Record<string, unknown> | null = null
let mockShowDebugAndPreviewPanel = false
let mockShowGlobalVariablePanel = false
@@ -138,7 +123,6 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
// Mock Panel component to capture props and render children
let capturedPanelProps: PanelProps | null = null
vi.mock('@/app/components/workflow/panel', () => ({
default: (props: PanelProps) => {
@@ -152,10 +136,6 @@ vi.mock('@/app/components/workflow/panel', () => ({
},
}))
// ============================================================================
// Helper Functions
// ============================================================================
type SetupMockOptions = {
historyWorkflowData?: Record<string, unknown> | null
showDebugAndPreviewPanel?: boolean
@@ -177,35 +157,24 @@ const setupMocks = (options?: SetupMockOptions) => {
capturedPanelProps = null
}
// ============================================================================
// RagPipelinePanel Component Tests
// ============================================================================
describe('RagPipelinePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
})
})
it('should render Panel component with correct structure', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('panel-left')).toBeInTheDocument()
expect(screen.getByTestId('panel-right')).toBeInTheDocument()
@@ -213,13 +182,10 @@ describe('RagPipelinePanel', () => {
})
it('should pass versionHistoryPanelProps to Panel', async () => {
// Arrange
setupMocks({ pipelineId: 'my-pipeline-456' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined()
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
@@ -229,18 +195,12 @@ describe('RagPipelinePanel', () => {
})
})
// -------------------------------------------------------------------------
// Memoization Tests - versionHistoryPanelProps
// -------------------------------------------------------------------------
describe('Memoization - versionHistoryPanelProps', () => {
it('should compute correct getVersionListUrl based on pipelineId', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-abc' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines/pipeline-abc/workflows',
@@ -249,13 +209,10 @@ describe('RagPipelinePanel', () => {
})
it('should compute correct deleteVersionUrl function', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-xyz' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const deleteUrl = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')
expect(deleteUrl).toBe('/rag/pipelines/pipeline-xyz/workflows/version-1')
@@ -263,13 +220,10 @@ describe('RagPipelinePanel', () => {
})
it('should compute correct updateVersionUrl function', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-def' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const updateUrl = capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-2')
expect(updateUrl).toBe('/rag/pipelines/pipeline-def/workflows/version-2')
@@ -277,63 +231,46 @@ describe('RagPipelinePanel', () => {
})
it('should set latestVersionId to empty string', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
})
})
})
// -------------------------------------------------------------------------
// Memoization Tests - panelProps
// -------------------------------------------------------------------------
describe('Memoization - panelProps', () => {
it('should pass components.left to Panel', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.components?.left).toBeDefined()
})
})
it('should pass components.right to Panel', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.components?.right).toBeDefined()
})
})
it('should pass versionHistoryPanelProps to panelProps', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined()
})
})
})
// -------------------------------------------------------------------------
// Component Memoization Tests (React.memo)
// -------------------------------------------------------------------------
describe('Component Memoization', () => {
it('should be wrapped with React.memo', async () => {
// The component should not break when re-rendered
const { rerender } = render(<RagPipelinePanel />)
// Act - rerender without prop changes
rerender(<RagPipelinePanel />)
// Assert - component should still render correctly
await waitFor(() => {
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
})
@@ -341,138 +278,98 @@ describe('RagPipelinePanel', () => {
})
})
// ============================================================================
// RagPipelinePanelOnRight Component Tests
// ============================================================================
describe('RagPipelinePanelOnRight', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Conditional Rendering - Record Panel
// -------------------------------------------------------------------------
describe('Record Panel Conditional Rendering', () => {
it('should render Record panel when historyWorkflowData exists', async () => {
// Arrange
setupMocks({ historyWorkflowData: { id: 'history-1' } })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
})
})
it('should not render Record panel when historyWorkflowData is null', async () => {
// Arrange
setupMocks({ historyWorkflowData: null })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
})
})
it('should not render Record panel when historyWorkflowData is undefined', async () => {
// Arrange
setupMocks({ historyWorkflowData: undefined })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - TestRun Panel
// -------------------------------------------------------------------------
describe('TestRun Panel Conditional Rendering', () => {
it('should render TestRun panel when showDebugAndPreviewPanel is true', async () => {
// Arrange
setupMocks({ showDebugAndPreviewPanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
})
})
it('should not render TestRun panel when showDebugAndPreviewPanel is false', async () => {
// Arrange
setupMocks({ showDebugAndPreviewPanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - GlobalVariable Panel
// -------------------------------------------------------------------------
describe('GlobalVariable Panel Conditional Rendering', () => {
it('should render GlobalVariable panel when showGlobalVariablePanel is true', async () => {
// Arrange
setupMocks({ showGlobalVariablePanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
})
})
it('should not render GlobalVariable panel when showGlobalVariablePanel is false', async () => {
// Arrange
setupMocks({ showGlobalVariablePanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('global-variable-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Multiple Panels Rendering
// -------------------------------------------------------------------------
describe('Multiple Panels Rendering', () => {
it('should render all right panels when all conditions are true', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showGlobalVariablePanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
@@ -481,17 +378,14 @@ describe('RagPipelinePanelOnRight', () => {
})
it('should render no right panels when all conditions are false', async () => {
// Arrange
setupMocks({
historyWorkflowData: null,
showDebugAndPreviewPanel: false,
showGlobalVariablePanel: false,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument()
@@ -500,17 +394,14 @@ describe('RagPipelinePanelOnRight', () => {
})
it('should render only Record and TestRun panels', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showGlobalVariablePanel: false,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
@@ -520,53 +411,36 @@ describe('RagPipelinePanelOnRight', () => {
})
})
// ============================================================================
// RagPipelinePanelOnLeft Component Tests
// ============================================================================
describe('RagPipelinePanelOnLeft', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Conditional Rendering - Preview Panel
// -------------------------------------------------------------------------
describe('Preview Panel Conditional Rendering', () => {
it('should render Preview panel when showInputFieldPreviewPanel is true', async () => {
// Arrange
setupMocks({ showInputFieldPreviewPanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
})
})
it('should not render Preview panel when showInputFieldPreviewPanel is false', async () => {
// Arrange
setupMocks({ showInputFieldPreviewPanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - InputFieldEditor Panel
// -------------------------------------------------------------------------
describe('InputFieldEditor Panel Conditional Rendering', () => {
it('should render InputFieldEditor panel when inputFieldEditPanelProps is provided', async () => {
// Arrange
const editProps = {
onClose: vi.fn(),
onSubmit: vi.fn(),
@@ -574,30 +448,24 @@ describe('RagPipelinePanelOnLeft', () => {
}
setupMocks({ inputFieldEditPanelProps: editProps })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
})
})
it('should not render InputFieldEditor panel when inputFieldEditPanelProps is null', async () => {
// Arrange
setupMocks({ inputFieldEditPanelProps: null })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
})
})
it('should pass props to InputFieldEditor panel', async () => {
// Arrange
const editProps = {
onClose: vi.fn(),
onSubmit: vi.fn(),
@@ -605,10 +473,8 @@ describe('RagPipelinePanelOnLeft', () => {
}
setupMocks({ inputFieldEditPanelProps: editProps })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(mockInputFieldEditorProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -621,53 +487,38 @@ describe('RagPipelinePanelOnLeft', () => {
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - InputField Panel
// -------------------------------------------------------------------------
describe('InputField Panel Conditional Rendering', () => {
it('should render InputField panel when showInputFieldPanel is true', async () => {
// Arrange
setupMocks({ showInputFieldPanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
})
})
it('should not render InputField panel when showInputFieldPanel is false', async () => {
// Arrange
setupMocks({ showInputFieldPanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('input-field-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Multiple Panels Rendering
// -------------------------------------------------------------------------
describe('Multiple Left Panels Rendering', () => {
it('should render all left panels when all conditions are true', async () => {
// Arrange
setupMocks({
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: { onClose: vi.fn(), onSubmit: vi.fn() },
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
@@ -676,17 +527,14 @@ describe('RagPipelinePanelOnLeft', () => {
})
it('should render no left panels when all conditions are false', async () => {
// Arrange
setupMocks({
showInputFieldPreviewPanel: false,
inputFieldEditPanelProps: null,
showInputFieldPanel: false,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
@@ -695,17 +543,14 @@ describe('RagPipelinePanelOnLeft', () => {
})
it('should render only Preview and InputField panels', async () => {
// Arrange
setupMocks({
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: null,
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
@@ -715,28 +560,18 @@ describe('RagPipelinePanelOnLeft', () => {
})
})
// ============================================================================
// Edge Cases Tests
// ============================================================================
describe('Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Empty/Undefined Values
// -------------------------------------------------------------------------
describe('Empty/Undefined Values', () => {
it('should handle empty pipelineId gracefully', async () => {
// Arrange
setupMocks({ pipelineId: '' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines//workflows',
@@ -745,13 +580,10 @@ describe('Edge Cases', () => {
})
it('should handle special characters in pipelineId', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-with-special_chars.123' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines/pipeline-with-special_chars.123/workflows',
@@ -760,12 +592,8 @@ describe('Edge Cases', () => {
})
})
// -------------------------------------------------------------------------
// Props Spreading Tests
// -------------------------------------------------------------------------
describe('Props Spreading', () => {
it('should correctly spread inputFieldEditPanelProps to editor component', async () => {
// Arrange
const customProps = {
onClose: vi.fn(),
onSubmit: vi.fn(),
@@ -778,10 +606,8 @@ describe('Edge Cases', () => {
}
setupMocks({ inputFieldEditPanelProps: customProps })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(mockInputFieldEditorProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -792,12 +618,8 @@ describe('Edge Cases', () => {
})
})
// -------------------------------------------------------------------------
// State Combinations
// -------------------------------------------------------------------------
describe('State Combinations', () => {
it('should handle all panels visible simultaneously', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'h1' },
showDebugAndPreviewPanel: true,
@@ -807,10 +629,8 @@ describe('Edge Cases', () => {
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert - All panels should be visible
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
@@ -823,10 +643,6 @@ describe('Edge Cases', () => {
})
})
// ============================================================================
// URL Generator Functions Tests
// ============================================================================
describe('URL Generator Functions', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -834,13 +650,10 @@ describe('URL Generator Functions', () => {
})
it('should return consistent URLs for same versionId', async () => {
// Arrange
setupMocks({ pipelineId: 'stable-pipeline' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x')
const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x')
@@ -849,13 +662,10 @@ describe('URL Generator Functions', () => {
})
it('should return different URLs for different versionIds', async () => {
// Arrange
setupMocks({ pipelineId: 'stable-pipeline' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')
const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-2')
@@ -866,10 +676,6 @@ describe('URL Generator Functions', () => {
})
})
// ============================================================================
// Type Safety Tests
// ============================================================================
describe('Type Safety', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -877,10 +683,8 @@ describe('Type Safety', () => {
})
it('should pass correct PanelProps structure', async () => {
// Act
render(<RagPipelinePanel />)
// Assert - Check structure matches PanelProps
await waitFor(() => {
expect(capturedPanelProps).toHaveProperty('components')
expect(capturedPanelProps).toHaveProperty('versionHistoryPanelProps')
@@ -890,10 +694,8 @@ describe('Type Safety', () => {
})
it('should pass correct versionHistoryPanelProps structure', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('getVersionListUrl')
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('deleteVersionUrl')
@@ -903,10 +705,6 @@ describe('Type Safety', () => {
})
})
// ============================================================================
// Performance Tests
// ============================================================================
describe('Performance', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -914,24 +712,17 @@ describe('Performance', () => {
})
it('should handle multiple rerenders without issues', async () => {
// Arrange
const { rerender } = render(<RagPipelinePanel />)
// Act - Multiple rerenders
for (let i = 0; i < 10; i++)
rerender(<RagPipelinePanel />)
// Assert - Component should still work
await waitFor(() => {
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -939,28 +730,23 @@ describe('Integration Tests', () => {
})
it('should pass correct components to Panel', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'h1' },
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.components?.left).toBeDefined()
expect(capturedPanelProps?.components?.right).toBeDefined()
// Check that the components are React elements
expect(React.isValidElement(capturedPanelProps?.components?.left)).toBe(true)
expect(React.isValidElement(capturedPanelProps?.components?.right)).toBe(true)
})
})
it('should correctly consume all store selectors', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'test-history' },
showDebugAndPreviewPanel: true,
@@ -971,10 +757,8 @@ describe('Integration Tests', () => {
pipelineId: 'integration-test-pipeline',
})
// Act
render(<RagPipelinePanel />)
// Assert - All store-dependent rendering should work
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()

View File

@@ -1,6 +1,6 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import FooterTip from './footer-tip'
import FooterTip from '../footer-tip'
afterEach(() => {
cleanup()
@@ -45,7 +45,6 @@ describe('FooterTip', () => {
it('should render the drag icon', () => {
const { container } = render(<FooterTip />)
// The RiDragDropLine icon should be rendered
const icon = container.querySelector('.size-4')
expect(icon).toBeInTheDocument()
})

View File

@@ -1,8 +1,7 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useFloatingRight } from './hooks'
import { useFloatingRight } from '../hooks'
// Mock reactflow
const mockGetNodes = vi.fn()
vi.mock('reactflow', () => ({
useStore: (selector: (s: { getNodes: () => { id: string, data: { selected: boolean } }[] }) => unknown) => {
@@ -10,12 +9,10 @@ vi.mock('reactflow', () => ({
},
}))
// Mock zustand/react/shallow
vi.mock('zustand/react/shallow', () => ({
useShallow: (fn: (...args: unknown[]) => unknown) => fn,
}))
// Mock workflow store
let mockNodePanelWidth = 400
let mockWorkflowCanvasWidth: number | undefined = 1200
let mockOtherPanelWidth = 0
@@ -67,8 +64,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(400))
// leftWidth = 1000 - 0 (no selected node) - 0 - 400 - 4 = 596
// 596 >= 404 so floatingRight should be false
expect(result.current.floatingRight).toBe(false)
})
})
@@ -80,8 +75,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(400))
// leftWidth = 1200 - 400 (node panel) - 0 - 400 - 4 = 396
// 396 < 404 so floatingRight should be true
expect(result.current.floatingRight).toBe(true)
})
})
@@ -103,7 +96,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(600))
// When floating and no selected node, width = min(600, 0 + 200) = 200
expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600)
})
@@ -115,7 +107,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(600))
// When floating with selected node, width = min(600, 300 + 100) = 400
expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600)
})
})
@@ -127,7 +118,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(400))
// Should not throw and should maintain initial state
expect(result.current.floatingRight).toBe(false)
})
@@ -145,7 +135,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(10000))
// Should be floating due to limited space
expect(result.current.floatingRight).toBe(true)
})
@@ -159,7 +148,6 @@ describe('useFloatingRight', () => {
const { result } = renderHook(() => useFloatingRight(400))
// Should have selected node so node panel is considered
expect(result.current).toBeDefined()
})
})

View File

@@ -5,19 +5,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import InputFieldPanel from './index'
import InputFieldPanel from '../index'
// ============================================================================
// Mock External Dependencies
// ============================================================================
// Mock reactflow hooks - use getter to allow dynamic updates
let mockNodesData: Node<DataSourceNodeType>[] = []
vi.mock('reactflow', () => ({
useNodes: () => mockNodesData,
}))
// Mock useInputFieldPanel hook
const mockCloseAllInputFieldPanels = vi.fn()
const mockToggleInputFieldPreviewPanel = vi.fn()
let mockIsPreviewing = false
@@ -32,7 +26,6 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({
}),
}))
// Mock useStore (workflow store)
let mockRagPipelineVariables: RAGPipelineVariables = []
const mockSetRagPipelineVariables = vi.fn()
@@ -56,7 +49,6 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
// Mock useNodesSyncDraft hook
const mockHandleSyncWorkflowDraft = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
@@ -65,8 +57,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}),
}))
// Mock FieldList component
vi.mock('./field-list', () => ({
vi.mock('../field-list', () => ({
default: ({
nodeId,
LabelRightContent,
@@ -124,13 +115,11 @@ vi.mock('./field-list', () => ({
),
}))
// Mock FooterTip component
vi.mock('./footer-tip', () => ({
vi.mock('../footer-tip', () => ({
default: () => <div data-testid="footer-tip">Footer Tip</div>,
}))
// Mock Datasource label component
vi.mock('./label-right-content/datasource', () => ({
vi.mock('../label-right-content/datasource', () => ({
default: ({ nodeData }: { nodeData: DataSourceNodeType }) => (
<div data-testid={`datasource-label-${nodeData.title}`}>
{nodeData.title}
@@ -138,15 +127,10 @@ vi.mock('./label-right-content/datasource', () => ({
),
}))
// Mock GlobalInputs label component
vi.mock('./label-right-content/global-inputs', () => ({
vi.mock('../label-right-content/global-inputs', () => ({
default: () => <div data-testid="global-inputs-label">Global Inputs</div>,
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({
type: PipelineInputVarType.textInput,
label: 'Test Label',
@@ -189,10 +173,6 @@ const createDataSourceNode = (
} as DataSourceNodeType,
})
// ============================================================================
// Helper Functions
// ============================================================================
const setupMocks = (options?: {
nodes?: Node<DataSourceNodeType>[]
ragPipelineVariables?: RAGPipelineVariables
@@ -205,148 +185,110 @@ const setupMocks = (options?: {
mockIsEditing = options?.isEditing || false
}
// ============================================================================
// InputFieldPanel Component Tests
// ============================================================================
describe('InputFieldPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render panel without crashing', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByText('datasetPipeline.inputFieldPanel.title'),
).toBeInTheDocument()
})
it('should render panel title correctly', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByText('datasetPipeline.inputFieldPanel.title'),
).toBeInTheDocument()
})
it('should render panel description', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByText('datasetPipeline.inputFieldPanel.description'),
).toBeInTheDocument()
})
it('should render preview button', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByText('datasetPipeline.operations.preview'),
).toBeInTheDocument()
})
it('should render close button', () => {
// Act
render(<InputFieldPanel />)
// Assert
const closeButton = screen.getByRole('button', { name: '' })
expect(closeButton).toBeInTheDocument()
})
it('should render footer tip component', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('footer-tip')).toBeInTheDocument()
})
it('should render unique inputs section title', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByText('datasetPipeline.inputFieldPanel.uniqueInputs.title'),
).toBeInTheDocument()
})
it('should render global inputs field list', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
expect(screen.getByTestId('global-inputs-label')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// DataSource Node Rendering Tests
// -------------------------------------------------------------------------
describe('DataSource Node Rendering', () => {
it('should render field list for each datasource node', () => {
// Arrange
const nodes = [
createDataSourceNode('node-1', 'DataSource 1'),
createDataSourceNode('node-2', 'DataSource 2'),
]
setupMocks({ nodes })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument()
expect(screen.getByTestId('field-list-node-2')).toBeInTheDocument()
})
it('should render datasource label for each node', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'My DataSource')]
setupMocks({ nodes })
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByTestId('datasource-label-My DataSource'),
).toBeInTheDocument()
})
it('should not render any datasource field lists when no nodes exist', () => {
// Arrange
setupMocks({ nodes: [] })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.queryByTestId('field-list-node-1')).not.toBeInTheDocument()
// Global inputs should still render
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
})
it('should filter only DataSource type nodes', () => {
// Arrange
const dataSourceNode = createDataSourceNode('ds-node', 'DataSource Node')
// Create a non-datasource node to verify filtering
const otherNode = {
id: 'other-node',
type: 'custom',
@@ -359,10 +301,8 @@ describe('InputFieldPanel', () => {
} as Node<DataSourceNodeType>
mockNodesData = [dataSourceNode, otherNode]
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-ds-node')).toBeInTheDocument()
expect(
screen.queryByTestId('field-list-other-node'),
@@ -370,12 +310,8 @@ describe('InputFieldPanel', () => {
})
})
// -------------------------------------------------------------------------
// Input Fields Map Tests
// -------------------------------------------------------------------------
describe('Input Fields Map', () => {
it('should correctly distribute variables to their nodes', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
const variables = [
createRAGPipelineVariable('node-1', { variable: 'var1' }),
@@ -384,28 +320,22 @@ describe('InputFieldPanel', () => {
]
setupMocks({ nodes, ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('2')
expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent('1')
})
it('should show zero fields for nodes without variables', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes, ragPipelineVariables: [] })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('0')
})
it('should pass all variable names to field lists', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
const variables = [
createRAGPipelineVariable('node-1', { variable: 'var1' }),
@@ -413,10 +343,8 @@ describe('InputFieldPanel', () => {
]
setupMocks({ nodes, ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-all-vars-node-1')).toHaveTextContent(
'var1,var2',
)
@@ -426,48 +354,35 @@ describe('InputFieldPanel', () => {
})
})
// -------------------------------------------------------------------------
// User Interactions Tests
// -------------------------------------------------------------------------
describe('User Interactions', () => {
// Helper to identify close button by its class
const isCloseButton = (btn: HTMLElement) =>
btn.classList.contains('size-6')
|| btn.className.includes('shrink-0 items-center justify-center p-0.5')
it('should call closeAllInputFieldPanels when close button is clicked', () => {
// Arrange
render(<InputFieldPanel />)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(isCloseButton)
// Act
fireEvent.click(closeButton!)
// Assert
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should call toggleInputFieldPreviewPanel when preview button is clicked', () => {
// Arrange
render(<InputFieldPanel />)
const previewButton = screen.getByText('datasetPipeline.operations.preview')
// Act
fireEvent.click(previewButton)
// Assert
expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(1)
})
it('should disable preview button when editing', () => {
// Arrange
setupMocks({ isEditing: true })
// Act
render(<InputFieldPanel />)
// Assert
const previewButton = screen
.getByText('datasetPipeline.operations.preview')
.closest('button')
@@ -475,13 +390,10 @@ describe('InputFieldPanel', () => {
})
it('should not disable preview button when not editing', () => {
// Arrange
setupMocks({ isEditing: false })
// Act
render(<InputFieldPanel />)
// Assert
const previewButton = screen
.getByText('datasetPipeline.operations.preview')
.closest('button')
@@ -489,18 +401,12 @@ describe('InputFieldPanel', () => {
})
})
// -------------------------------------------------------------------------
// Preview State Tests
// -------------------------------------------------------------------------
describe('Preview State', () => {
it('should apply active styling when previewing', () => {
// Arrange
setupMocks({ isPreviewing: true })
// Act
render(<InputFieldPanel />)
// Assert
const previewButton = screen
.getByText('datasetPipeline.operations.preview')
.closest('button')
@@ -509,81 +415,62 @@ describe('InputFieldPanel', () => {
})
it('should set readonly to true when previewing', () => {
// Arrange
setupMocks({ isPreviewing: true })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent(
'true',
)
})
it('should set readonly to true when editing', () => {
// Arrange
setupMocks({ isEditing: true })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent(
'true',
)
})
it('should set readonly to false when not previewing or editing', () => {
// Arrange
setupMocks({ isPreviewing: false, isEditing: false })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent(
'false',
)
})
})
// -------------------------------------------------------------------------
// Input Fields Change Handler Tests
// -------------------------------------------------------------------------
describe('Input Fields Change Handler', () => {
it('should update rag pipeline variables when input fields change', async () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes })
render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
// Assert
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
})
})
it('should call handleSyncWorkflowDraft when fields change', async () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes })
render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
// Assert
await waitFor(() => {
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
})
})
it('should place datasource node fields before global fields', async () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
const variables = [
createRAGPipelineVariable('shared', { variable: 'shared_var' }),
@@ -591,15 +478,12 @@ describe('InputFieldPanel', () => {
setupMocks({ nodes, ragPipelineVariables: variables })
render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
// Assert
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
})
// Verify datasource fields come before shared fields
const setVarsCall = mockSetRagPipelineVariables.mock.calls[0][0] as RAGPipelineVariables
const isNotShared = (v: RAGPipelineVariable) => v.belong_to_node_id !== 'shared'
const isShared = (v: RAGPipelineVariable) => v.belong_to_node_id === 'shared'
@@ -614,7 +498,6 @@ describe('InputFieldPanel', () => {
})
it('should handle removing all fields from a node', async () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
const variables = [
createRAGPipelineVariable('node-1', { variable: 'var1' }),
@@ -623,24 +506,19 @@ describe('InputFieldPanel', () => {
setupMocks({ nodes, ragPipelineVariables: variables })
render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByTestId('trigger-remove-node-1'))
// Assert
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
})
})
it('should update global input fields correctly', async () => {
// Arrange
setupMocks()
render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByTestId('trigger-change-shared'))
// Assert
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
})
@@ -652,54 +530,39 @@ describe('InputFieldPanel', () => {
})
})
// -------------------------------------------------------------------------
// Label Class Name Tests
// -------------------------------------------------------------------------
describe('Label Class Names', () => {
it('should pass correct className to datasource field lists', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes })
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByTestId('field-list-classname-node-1'),
).toHaveTextContent('pt-1 pb-1')
})
it('should pass correct className to global inputs field list', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-classname-shared')).toHaveTextContent(
'pt-2 pb-1',
)
})
})
// -------------------------------------------------------------------------
// Memoization Tests
// -------------------------------------------------------------------------
describe('Memoization', () => {
it('should memoize datasourceNodeDataMap based on nodes', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes })
const { rerender } = render(<InputFieldPanel />)
// Act - rerender with same nodes reference
rerender(<InputFieldPanel />)
// Assert - component should not break and should render correctly
expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument()
})
it('should compute allVariableNames correctly', () => {
// Arrange
const variables = [
createRAGPipelineVariable('node-1', { variable: 'alpha' }),
createRAGPipelineVariable('node-1', { variable: 'beta' }),
@@ -707,21 +570,15 @@ describe('InputFieldPanel', () => {
]
setupMocks({ ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
'alpha,beta,gamma',
)
})
})
// -------------------------------------------------------------------------
// Callback Stability Tests
// -------------------------------------------------------------------------
describe('Callback Stability', () => {
// Helper to find close button - moved outside test to reduce nesting
const findCloseButton = (buttons: HTMLElement[]) => {
const isCloseButton = (btn: HTMLElement) =>
btn.classList.contains('size-6')
@@ -730,10 +587,8 @@ describe('InputFieldPanel', () => {
}
it('should maintain closePanel callback reference', () => {
// Arrange
const { rerender } = render(<InputFieldPanel />)
// Act
const buttons1 = screen.getAllByRole('button')
fireEvent.click(findCloseButton(buttons1)!)
const callCount1 = mockCloseAllInputFieldPanels.mock.calls.length
@@ -742,126 +597,97 @@ describe('InputFieldPanel', () => {
const buttons2 = screen.getAllByRole('button')
fireEvent.click(findCloseButton(buttons2)!)
// Assert
expect(mockCloseAllInputFieldPanels.mock.calls.length).toBe(callCount1 + 1)
})
it('should maintain togglePreviewPanel callback reference', () => {
// Arrange
const { rerender } = render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByText('datasetPipeline.operations.preview'))
const callCount1 = mockToggleInputFieldPreviewPanel.mock.calls.length
rerender(<InputFieldPanel />)
fireEvent.click(screen.getByText('datasetPipeline.operations.preview'))
// Assert
expect(mockToggleInputFieldPreviewPanel.mock.calls.length).toBe(
callCount1 + 1,
)
})
})
// -------------------------------------------------------------------------
// Edge Cases Tests
// -------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty ragPipelineVariables', () => {
// Arrange
setupMocks({ ragPipelineVariables: [] })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
'',
)
})
it('should handle undefined ragPipelineVariables', () => {
// Arrange - intentionally testing undefined case
// @ts-expect-error Testing edge case with undefined value
mockRagPipelineVariables = undefined
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
})
it('should handle null variable names in allVariableNames', () => {
// Arrange - intentionally testing edge case with empty variable name
const variables = [
createRAGPipelineVariable('node-1', { variable: 'valid_var' }),
createRAGPipelineVariable('node-1', { variable: '' }),
]
setupMocks({ ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert - should not crash
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
})
it('should handle large number of datasource nodes', () => {
// Arrange
const nodes = Array.from({ length: 10 }, (_, i) =>
createDataSourceNode(`node-${i}`, `DataSource ${i}`))
setupMocks({ nodes })
// Act
render(<InputFieldPanel />)
// Assert
nodes.forEach((_, i) => {
expect(screen.getByTestId(`field-list-node-${i}`)).toBeInTheDocument()
})
})
it('should handle large number of variables', () => {
// Arrange
const variables = Array.from({ length: 100 }, (_, i) =>
createRAGPipelineVariable('shared', { variable: `var_${i}` }))
setupMocks({ ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent(
'100',
)
})
it('should handle special characters in variable names', () => {
// Arrange
const variables = [
createRAGPipelineVariable('shared', { variable: 'var_with_underscore' }),
createRAGPipelineVariable('shared', { variable: 'varWithCamelCase' }),
]
setupMocks({ ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
'var_with_underscore,varWithCamelCase',
)
})
})
// -------------------------------------------------------------------------
// Multiple Nodes Interaction Tests
// -------------------------------------------------------------------------
describe('Multiple Nodes Interaction', () => {
it('should handle changes to multiple nodes sequentially', async () => {
// Arrange
const nodes = [
createDataSourceNode('node-1', 'DataSource 1'),
createDataSourceNode('node-2', 'DataSource 2'),
@@ -869,18 +695,15 @@ describe('InputFieldPanel', () => {
setupMocks({ nodes })
render(<InputFieldPanel />)
// Act
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
fireEvent.click(screen.getByTestId('trigger-change-node-2'))
// Assert
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalledTimes(2)
})
})
it('should maintain separate field lists for different nodes', () => {
// Arrange
const nodes = [
createDataSourceNode('node-1', 'DataSource 1'),
createDataSourceNode('node-2', 'DataSource 2'),
@@ -892,42 +715,31 @@ describe('InputFieldPanel', () => {
]
setupMocks({ nodes, ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('1')
expect(screen.getByTestId('field-list-fields-count-node-2')).toHaveTextContent('2')
})
})
// -------------------------------------------------------------------------
// Component Structure Tests
// -------------------------------------------------------------------------
describe('Component Structure', () => {
it('should have correct panel width class', () => {
// Act
const { container } = render(<InputFieldPanel />)
// Assert
const panel = container.firstChild as HTMLElement
expect(panel).toHaveClass('w-[400px]')
})
it('should have overflow scroll on content area', () => {
// Act
const { container } = render(<InputFieldPanel />)
// Assert
const scrollContainer = container.querySelector('.overflow-y-auto')
expect(scrollContainer).toBeInTheDocument()
})
it('should render header section with proper spacing', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByText('datasetPipeline.inputFieldPanel.title'),
).toBeInTheDocument()
@@ -937,12 +749,8 @@ describe('InputFieldPanel', () => {
})
})
// -------------------------------------------------------------------------
// Integration with FieldList Component Tests
// -------------------------------------------------------------------------
describe('Integration with FieldList Component', () => {
it('should pass correct props to FieldList for datasource nodes', () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
const variables = [
createRAGPipelineVariable('node-1', { variable: 'test_var' }),
@@ -953,38 +761,29 @@ describe('InputFieldPanel', () => {
isPreviewing: true,
})
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument()
expect(screen.getByTestId('field-list-readonly-node-1')).toHaveTextContent('true')
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('1')
})
it('should pass correct props to FieldList for shared node', () => {
// Arrange
const variables = [
createRAGPipelineVariable('shared', { variable: 'shared_var' }),
]
setupMocks({ ragPipelineVariables: variables, isEditing: true })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent('true')
expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent('1')
})
})
// -------------------------------------------------------------------------
// Variable Ordering Tests
// -------------------------------------------------------------------------
describe('Variable Ordering', () => {
it('should maintain correct variable order in allVariableNames', () => {
// Arrange
const variables = [
createRAGPipelineVariable('node-1', { variable: 'first' }),
createRAGPipelineVariable('node-1', { variable: 'second' }),
@@ -992,10 +791,8 @@ describe('InputFieldPanel', () => {
]
setupMocks({ ragPipelineVariables: variables })
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
'first,second,third',
)
@@ -1003,13 +800,8 @@ describe('InputFieldPanel', () => {
})
})
// ============================================================================
// useFloatingRight Hook Integration Tests (via InputFieldPanel)
// ============================================================================
describe('useFloatingRight Hook Integration', () => {
// Note: The hook is tested indirectly through the InputFieldPanel component
// as it's used internally. Direct hook tests are in hooks.spec.tsx if exists.
beforeEach(() => {
vi.clearAllMocks()
@@ -1017,16 +809,11 @@ describe('useFloatingRight Hook Integration', () => {
})
it('should render panel correctly with default floating state', () => {
// The hook is mocked via the component's behavior
render(<InputFieldPanel />)
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
})
})
// ============================================================================
// FooterTip Component Integration Tests
// ============================================================================
describe('FooterTip Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1034,18 +821,12 @@ describe('FooterTip Integration', () => {
})
it('should render footer tip at the bottom of the panel', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('footer-tip')).toBeInTheDocument()
})
})
// ============================================================================
// Label Components Integration Tests
// ============================================================================
describe('Label Components Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1053,25 +834,20 @@ describe('Label Components Integration', () => {
})
it('should render GlobalInputs label for shared field list', () => {
// Act
render(<InputFieldPanel />)
// Assert
expect(screen.getByTestId('global-inputs-label')).toBeInTheDocument()
})
it('should render Datasource label for each datasource node', () => {
// Arrange
const nodes = [
createDataSourceNode('node-1', 'First DataSource'),
createDataSourceNode('node-2', 'Second DataSource'),
]
setupMocks({ nodes })
// Act
render(<InputFieldPanel />)
// Assert
expect(
screen.getByTestId('datasource-label-First DataSource'),
).toBeInTheDocument()
@@ -1081,10 +857,6 @@ describe('Label Components Integration', () => {
})
})
// ============================================================================
// Component Memo Tests
// ============================================================================
describe('Component Memo Behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1092,14 +864,10 @@ describe('Component Memo Behavior', () => {
})
it('should be wrapped with React.memo', () => {
// InputFieldPanel is exported as memo(InputFieldPanel)
// This test ensures the component doesn't break memoization
const { rerender } = render(<InputFieldPanel />)
// Act - rerender without prop changes
rerender(<InputFieldPanel />)
// Assert - component should still render correctly
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
expect(
screen.getByText('datasetPipeline.inputFieldPanel.title'),
@@ -1107,15 +875,12 @@ describe('Component Memo Behavior', () => {
})
it('should handle state updates correctly with memo', async () => {
// Arrange
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes })
render(<InputFieldPanel />)
// Act - trigger a state change
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
// Assert
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
})

View File

@@ -0,0 +1,366 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PipelineInputVarType } from '@/models/pipeline'
import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks'
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFileSizeLimit: () => ({
imgSizeLimit: 10 * 1024 * 1024,
docSizeLimit: 15 * 1024 * 1024,
audioSizeLimit: 50 * 1024 * 1024,
videoSizeLimit: 100 * 1024 * 1024,
}),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({ data: {} }),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DEFAULT_FILE_UPLOAD_SETTING: {
allowed_file_upload_methods: ['local_file', 'remote_url'],
allowed_file_types: ['image', 'document'],
allowed_file_extensions: ['.jpg', '.png', '.pdf'],
max_length: 5,
},
}))
vi.mock('../schema', () => ({
TEXT_MAX_LENGTH: 256,
}))
vi.mock('@/utils/format', () => ({
formatFileSize: (size: number) => `${Math.round(size / 1024 / 1024)}MB`,
}))
describe('useHiddenFieldNames', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('should return field names for textInput type', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput))
expect(result.current).toContain('variableconfig.defaultvalue')
expect(result.current).toContain('variableconfig.placeholder')
expect(result.current).toContain('variableconfig.tooltips')
})
it('should return field names for paragraph type', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.paragraph))
expect(result.current).toContain('variableconfig.defaultvalue')
expect(result.current).toContain('variableconfig.placeholder')
expect(result.current).toContain('variableconfig.tooltips')
})
it('should return field names for number type including unit', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.number))
expect(result.current).toContain('appdebug.variableconfig.defaultvalue')
expect(result.current).toContain('appdebug.variableconfig.unit')
expect(result.current).toContain('appdebug.variableconfig.placeholder')
expect(result.current).toContain('appdebug.variableconfig.tooltips')
})
it('should return field names for select type', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.select))
expect(result.current).toContain('appdebug.variableconfig.defaultvalue')
expect(result.current).toContain('appdebug.variableconfig.tooltips')
})
it('should return field names for singleFile type', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.singleFile))
expect(result.current).toContain('appdebug.variableconfig.uploadmethod')
expect(result.current).toContain('appdebug.variableconfig.tooltips')
})
it('should return field names for multiFiles type including max number', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.multiFiles))
expect(result.current).toContain('appdebug.variableconfig.uploadmethod')
expect(result.current).toContain('appdebug.variableconfig.maxnumberofuploads')
expect(result.current).toContain('appdebug.variableconfig.tooltips')
})
it('should return field names for checkbox type', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.checkbox))
expect(result.current).toContain('appdebug.variableconfig.startchecked')
expect(result.current).toContain('appdebug.variableconfig.tooltips')
})
it('should return only tooltips for unknown type', () => {
const { result } = renderHook(() => useHiddenFieldNames('unknown-type' as PipelineInputVarType))
expect(result.current).toBe('appdebug.variableconfig.tooltips')
})
it('should return comma-separated lowercase string', () => {
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput))
expect(result.current).toMatch(/,/)
expect(result.current).toBe(result.current.toLowerCase())
})
})
describe('useConfigurations', () => {
let mockGetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>>
let mockSetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
beforeEach(() => {
mockGetFieldValue = vi.fn()
mockSetFieldValue = vi.fn()
vi.clearAllMocks()
})
it('should return array of configurations', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
expect(Array.isArray(result.current)).toBe(true)
expect(result.current.length).toBeGreaterThan(0)
})
it('should include field type select configuration', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const typeConfig = result.current.find(c => c.variable === 'type')
expect(typeConfig).toBeDefined()
expect(typeConfig?.required).toBe(true)
})
it('should include variable name configuration', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const varConfig = result.current.find(c => c.variable === 'variable')
expect(varConfig).toBeDefined()
expect(varConfig?.required).toBe(true)
})
it('should include display name configuration', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const labelConfig = result.current.find(c => c.variable === 'label')
expect(labelConfig).toBeDefined()
expect(labelConfig?.required).toBe(false)
})
it('should include required checkbox configuration', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const requiredConfig = result.current.find(c => c.variable === 'required')
expect(requiredConfig).toBeDefined()
})
it('should set file defaults when type changes to singleFile', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const typeConfig = result.current.find(c => c.variable === 'type')
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.singleFile, fieldApi: {} as never })
expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', ['local_file', 'remote_url'])
expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', {
allowedFileTypes: ['image', 'document'],
allowedFileExtensions: ['.jpg', '.png', '.pdf'],
})
})
it('should set maxLength when type changes to multiFiles', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const typeConfig = result.current.find(c => c.variable === 'type')
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.multiFiles, fieldApi: {} as never })
expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', 5)
})
it('should not set file defaults when type changes to text', () => {
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const typeConfig = result.current.find(c => c.variable === 'type')
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.textInput, fieldApi: {} as never })
expect(mockSetFieldValue).not.toHaveBeenCalled()
})
it('should auto-fill label from variable name on blur', () => {
mockGetFieldValue.mockReturnValue('')
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const varConfig = result.current.find(c => c.variable === 'variable')
varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never })
expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'myVariable')
})
it('should not auto-fill label if label already exists', () => {
mockGetFieldValue.mockReturnValue('Existing Label')
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const varConfig = result.current.find(c => c.variable === 'variable')
varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never })
expect(mockSetFieldValue).not.toHaveBeenCalled()
})
it('should reset label to variable name when display name is cleared', () => {
mockGetFieldValue.mockReturnValue('existingVar')
const { result } = renderHook(() =>
useConfigurations({
getFieldValue: mockGetFieldValue,
setFieldValue: mockSetFieldValue,
supportFile: true,
}),
)
const labelConfig = result.current.find(c => c.variable === 'label')
labelConfig?.listeners?.onBlur?.({ value: '', fieldApi: {} as never })
expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'existingVar')
})
})
describe('useHiddenConfigurations', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('should return array of hidden configurations', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: undefined }),
)
expect(Array.isArray(result.current)).toBe(true)
expect(result.current.length).toBeGreaterThan(0)
})
it('should include default value config for textInput', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: undefined }),
)
const defaultConfigs = result.current.filter(c => c.variable === 'default')
expect(defaultConfigs.length).toBeGreaterThan(0)
})
it('should include tooltips configuration for all types', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: undefined }),
)
const tooltipsConfig = result.current.find(c => c.variable === 'tooltips')
expect(tooltipsConfig).toBeDefined()
expect(tooltipsConfig?.showConditions).toEqual([])
})
it('should build select options from provided options', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: ['opt1', 'opt2'] }),
)
const selectDefault = result.current.find(
c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select),
)
expect(selectDefault?.options).toBeDefined()
expect(selectDefault?.options?.[0]?.value).toBe('')
expect(selectDefault?.options?.[1]?.value).toBe('opt1')
expect(selectDefault?.options?.[2]?.value).toBe('opt2')
})
it('should return empty options when options prop is undefined', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: undefined }),
)
const selectDefault = result.current.find(
c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select),
)
expect(selectDefault?.options).toEqual([])
})
it('should include upload method configs for file types', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: undefined }),
)
const uploadMethods = result.current.filter(c => c.variable === 'allowedFileUploadMethods')
expect(uploadMethods.length).toBe(2) // singleFile + multiFiles
})
it('should include maxLength slider for multiFiles', () => {
const { result } = renderHook(() =>
useHiddenConfigurations({ options: undefined }),
)
const maxLength = result.current.find(
c => c.variable === 'maxLength' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.multiFiles),
)
expect(maxLength).toBeDefined()
expect(maxLength?.description).toBeDefined()
})
})

View File

@@ -0,0 +1,260 @@
import type { TFunction } from 'i18next'
import { describe, expect, it, vi } from 'vitest'
import { PipelineInputVarType } from '@/models/pipeline'
import { createInputFieldSchema, TEXT_MAX_LENGTH } from '../schema'
vi.mock('@/config', () => ({
MAX_VAR_KEY_LENGTH: 30,
}))
const t: TFunction = ((key: string) => key) as unknown as TFunction
const defaultOptions = { maxFileUploadLimit: 10 }
describe('TEXT_MAX_LENGTH', () => {
it('should be 256', () => {
expect(TEXT_MAX_LENGTH).toBe(256)
})
})
describe('createInputFieldSchema', () => {
describe('common schema validation', () => {
it('should reject empty variable name', () => {
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: '',
label: 'Test',
required: false,
maxLength: 48,
})
expect(result.success).toBe(false)
})
it('should reject variable starting with number', () => {
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: '123abc',
label: 'Test',
required: false,
maxLength: 48,
})
expect(result.success).toBe(false)
})
it('should accept valid variable name', () => {
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: 'valid_var',
label: 'Test',
required: false,
maxLength: 48,
})
expect(result.success).toBe(true)
})
it('should reject empty label', () => {
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: 'my_var',
label: '',
required: false,
maxLength: 48,
})
expect(result.success).toBe(false)
})
})
describe('text input type', () => {
it('should validate maxLength within range', () => {
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const valid = schema.safeParse({
type: 'text-input',
variable: 'text_var',
label: 'Text',
required: false,
maxLength: 100,
})
expect(valid.success).toBe(true)
const tooLow = schema.safeParse({
type: 'text-input',
variable: 'text_var',
label: 'Text',
required: false,
maxLength: 0,
})
expect(tooLow.success).toBe(false)
})
it('should allow optional default and tooltips', () => {
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
const result = schema.safeParse({
type: 'text-input',
variable: 'text_var',
label: 'Text',
required: false,
maxLength: 48,
default: 'default value',
tooltips: 'Some help text',
})
expect(result.success).toBe(true)
})
})
describe('paragraph type', () => {
it('should use same schema as text input', () => {
const schema = createInputFieldSchema(PipelineInputVarType.paragraph, t, defaultOptions)
const result = schema.safeParse({
type: 'paragraph',
variable: 'para_var',
label: 'Paragraph',
required: false,
maxLength: 100,
})
expect(result.success).toBe(true)
})
})
describe('number type', () => {
it('should allow optional unit and placeholder', () => {
const schema = createInputFieldSchema(PipelineInputVarType.number, t, defaultOptions)
const result = schema.safeParse({
type: 'number',
variable: 'num_var',
label: 'Number',
required: false,
default: 42,
unit: 'kg',
placeholder: 'Enter weight',
})
expect(result.success).toBe(true)
})
})
describe('select type', () => {
it('should require non-empty options array', () => {
const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions)
const empty = schema.safeParse({
type: 'select',
variable: 'sel_var',
label: 'Select',
required: false,
options: [],
})
expect(empty.success).toBe(false)
const valid = schema.safeParse({
type: 'select',
variable: 'sel_var',
label: 'Select',
required: false,
options: ['opt1', 'opt2'],
})
expect(valid.success).toBe(true)
})
it('should reject duplicate options', () => {
const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions)
const result = schema.safeParse({
type: 'select',
variable: 'sel_var',
label: 'Select',
required: false,
options: ['opt1', 'opt1'],
})
expect(result.success).toBe(false)
})
})
describe('singleFile type', () => {
it('should require file upload methods and types', () => {
const schema = createInputFieldSchema(PipelineInputVarType.singleFile, t, defaultOptions)
const result = schema.safeParse({
type: 'file',
variable: 'file_var',
label: 'File',
required: false,
allowedFileUploadMethods: ['local_file'],
allowedTypesAndExtensions: {
allowedFileTypes: ['document'],
},
})
expect(result.success).toBe(true)
})
})
describe('multiFiles type', () => {
it('should validate maxLength against maxFileUploadLimit', () => {
const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, t, { maxFileUploadLimit: 5 })
const valid = schema.safeParse({
type: 'file-list',
variable: 'files_var',
label: 'Files',
required: false,
allowedFileUploadMethods: ['local_file'],
allowedTypesAndExtensions: {
allowedFileTypes: ['image'],
},
maxLength: 3,
})
expect(valid.success).toBe(true)
const tooMany = schema.safeParse({
type: 'file-list',
variable: 'files_var',
label: 'Files',
required: false,
allowedFileUploadMethods: ['local_file'],
allowedTypesAndExtensions: {
allowedFileTypes: ['image'],
},
maxLength: 10,
})
expect(tooMany.success).toBe(false)
})
})
describe('checkbox / default type', () => {
it('should use common schema for checkbox type', () => {
const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions)
const result = schema.safeParse({
type: 'checkbox',
variable: 'check_var',
label: 'Agree',
required: true,
})
expect(result.success).toBe(true)
})
it('should allow passthrough of extra fields', () => {
const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions)
const result = schema.safeParse({
type: 'checkbox',
variable: 'check_var',
label: 'Agree',
required: true,
default: true,
extraField: 'should pass through',
})
expect(result.success).toBe(true)
})
})
})

View File

@@ -1,6 +1,6 @@
import type { TFunction } from 'i18next'
import type { SchemaOptions } from './types'
import { z } from 'zod'
import * as z from 'zod'
import { InputTypeEnum } from '@/app/components/base/form/components/field/input-type-select/types'
import { MAX_VAR_KEY_LENGTH } from '@/config'
import { PipelineInputVarType } from '@/models/pipeline'

View File

@@ -0,0 +1,371 @@
import type { InputVar } from '@/models/pipeline'
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useFieldList } from '../hooks'
const mockToggleInputFieldEditPanel = vi.fn()
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
toggleInputFieldEditPanel: mockToggleInputFieldEditPanel,
}),
}))
const mockHandleInputVarRename = vi.fn()
const mockIsVarUsedInNodes = vi.fn()
const mockRemoveUsedVarInNodes = vi.fn()
vi.mock('../../../../../hooks/use-pipeline', () => ({
usePipeline: () => ({
handleInputVarRename: mockHandleInputVarRename,
isVarUsedInNodes: mockIsVarUsedInNodes,
removeUsedVarInNodes: mockRemoveUsedVarInNodes,
}),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (...args: unknown[]) => mockToastNotify(...args),
},
}))
vi.mock('@/app/components/workflow/types', () => ({
ChangeType: {
changeVarName: 'changeVarName',
remove: 'remove',
},
}))
function createInputVar(overrides?: Partial<InputVar>): InputVar {
return {
type: 'text-input',
variable: 'test_var',
label: 'Test Var',
required: false,
...overrides,
} as InputVar
}
function createDefaultProps(overrides?: Partial<Parameters<typeof useFieldList>[0]>) {
return {
initialInputFields: [] as InputVar[],
onInputFieldsChange: vi.fn(),
nodeId: 'node-1',
allVariableNames: [] as string[],
...overrides,
}
}
describe('useFieldList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsVarUsedInNodes.mockReturnValue(false)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should return inputFields from initialInputFields', () => {
const fields = [createInputVar({ variable: 'var1' })]
const { result } = renderHook(() => useFieldList(createDefaultProps({ initialInputFields: fields })))
expect(result.current.inputFields).toEqual(fields)
})
it('should return empty inputFields when initialized with empty array', () => {
const { result } = renderHook(() => useFieldList(createDefaultProps()))
expect(result.current.inputFields).toEqual([])
})
it('should return all expected functions', () => {
const { result } = renderHook(() => useFieldList(createDefaultProps()))
expect(typeof result.current.handleListSortChange).toBe('function')
expect(typeof result.current.handleRemoveField).toBe('function')
expect(typeof result.current.handleOpenInputFieldEditor).toBe('function')
expect(typeof result.current.hideRemoveVarConfirm).toBe('function')
expect(typeof result.current.onRemoveVarConfirm).toBe('function')
})
it('should have isShowRemoveVarConfirm as false initially', () => {
const { result } = renderHook(() => useFieldList(createDefaultProps()))
expect(result.current.isShowRemoveVarConfirm).toBe(false)
})
})
describe('handleListSortChange', () => {
it('should reorder input fields and notify parent', () => {
const var1 = createInputVar({ variable: 'var1', label: 'V1' })
const var2 = createInputVar({ variable: 'var2', label: 'V2' })
const onInputFieldsChange = vi.fn()
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1, var2],
onInputFieldsChange,
})),
)
act(() => {
result.current.handleListSortChange([
{ ...var2, id: '1', chosen: false, selected: false },
{ ...var1, id: '0', chosen: false, selected: false },
])
})
expect(onInputFieldsChange).toHaveBeenCalledWith([var2, var1])
})
it('should strip sortable metadata (id, chosen, selected) from items', () => {
const var1 = createInputVar({ variable: 'var1' })
const onInputFieldsChange = vi.fn()
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1],
onInputFieldsChange,
})),
)
act(() => {
result.current.handleListSortChange([
{ ...var1, id: '0', chosen: true, selected: true },
])
})
const updatedFields = onInputFieldsChange.mock.calls[0][0]
expect(updatedFields[0]).not.toHaveProperty('id')
expect(updatedFields[0]).not.toHaveProperty('chosen')
expect(updatedFields[0]).not.toHaveProperty('selected')
})
})
describe('handleRemoveField', () => {
it('should remove field when variable is not used in nodes', () => {
const var1 = createInputVar({ variable: 'var1' })
const var2 = createInputVar({ variable: 'var2' })
const onInputFieldsChange = vi.fn()
mockIsVarUsedInNodes.mockReturnValue(false)
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1, var2],
onInputFieldsChange,
})),
)
act(() => {
result.current.handleRemoveField(0)
})
expect(onInputFieldsChange).toHaveBeenCalledWith([var2])
})
it('should show confirmation when variable is used in other nodes', () => {
const var1 = createInputVar({ variable: 'used_var' })
const onInputFieldsChange = vi.fn()
mockIsVarUsedInNodes.mockReturnValue(true)
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1],
onInputFieldsChange,
})),
)
act(() => {
result.current.handleRemoveField(0)
})
expect(result.current.isShowRemoveVarConfirm).toBe(true)
expect(onInputFieldsChange).not.toHaveBeenCalled()
})
})
describe('onRemoveVarConfirm', () => {
it('should remove field and clean up variable references after confirmation', () => {
const var1 = createInputVar({ variable: 'used_var' })
const onInputFieldsChange = vi.fn()
mockIsVarUsedInNodes.mockReturnValue(true)
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1],
onInputFieldsChange,
nodeId: 'node-1',
})),
)
act(() => {
result.current.handleRemoveField(0)
})
expect(result.current.isShowRemoveVarConfirm).toBe(true)
act(() => {
result.current.onRemoveVarConfirm()
})
expect(onInputFieldsChange).toHaveBeenCalledWith([])
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['rag', 'node-1', 'used_var'])
expect(result.current.isShowRemoveVarConfirm).toBe(false)
})
})
describe('handleOpenInputFieldEditor', () => {
it('should open editor with existing field data when id matches', () => {
const var1 = createInputVar({ variable: 'var1', label: 'Label 1' })
const { result } = renderHook(() =>
useFieldList(createDefaultProps({ initialInputFields: [var1] })),
)
act(() => {
result.current.handleOpenInputFieldEditor('var1')
})
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
expect.objectContaining({
initialData: var1,
}),
)
})
it('should open editor for new field when id does not match', () => {
const { result } = renderHook(() =>
useFieldList(createDefaultProps()),
)
act(() => {
result.current.handleOpenInputFieldEditor('non-existent')
})
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
expect.objectContaining({
initialData: undefined,
}),
)
})
it('should open editor for new field when no id provided', () => {
const { result } = renderHook(() =>
useFieldList(createDefaultProps()),
)
act(() => {
result.current.handleOpenInputFieldEditor()
})
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
expect.objectContaining({
initialData: undefined,
}),
)
})
})
describe('field submission (via editor)', () => {
it('should add new field when editingFieldIndex is -1', () => {
const onInputFieldsChange = vi.fn()
const { result } = renderHook(() =>
useFieldList(createDefaultProps({ onInputFieldsChange })),
)
act(() => {
result.current.handleOpenInputFieldEditor()
})
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
const newField = createInputVar({ variable: 'new_var', label: 'New' })
act(() => {
editorProps.onSubmit(newField)
})
expect(onInputFieldsChange).toHaveBeenCalledWith([newField])
})
it('should show error toast for duplicate variable names', () => {
const var1 = createInputVar({ variable: 'existing_var' })
const onInputFieldsChange = vi.fn()
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1],
onInputFieldsChange,
allVariableNames: ['existing_var'],
})),
)
act(() => {
result.current.handleOpenInputFieldEditor()
})
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
const duplicateField = createInputVar({ variable: 'existing_var' })
act(() => {
editorProps.onSubmit(duplicateField)
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(onInputFieldsChange).not.toHaveBeenCalled()
})
it('should trigger variable rename when ChangeType is changeVarName', () => {
const var1 = createInputVar({ variable: 'old_name' })
const onInputFieldsChange = vi.fn()
const { result } = renderHook(() =>
useFieldList(createDefaultProps({
initialInputFields: [var1],
onInputFieldsChange,
nodeId: 'node-1',
allVariableNames: ['old_name'],
})),
)
act(() => {
result.current.handleOpenInputFieldEditor('old_name')
})
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
const updatedField = createInputVar({ variable: 'new_name' })
act(() => {
editorProps.onSubmit(updatedField, {
type: 'changeVarName',
payload: { beforeKey: 'old_name', afterKey: 'new_name' },
})
})
expect(mockHandleInputVarRename).toHaveBeenCalledWith(
'node-1',
['rag', 'node-1', 'old_name'],
['rag', 'node-1', 'new_name'],
)
})
})
describe('hideRemoveVarConfirm', () => {
it('should hide the confirmation dialog', () => {
const var1 = createInputVar({ variable: 'used_var' })
mockIsVarUsedInNodes.mockReturnValue(true)
const { result } = renderHook(() =>
useFieldList(createDefaultProps({ initialInputFields: [var1] })),
)
act(() => {
result.current.handleRemoveField(0)
})
expect(result.current.isShowRemoveVarConfirm).toBe(true)
act(() => {
result.current.hideRemoveVarConfirm()
})
expect(result.current.isShowRemoveVarConfirm).toBe(false)
})
})
})

View File

@@ -2,17 +2,9 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import Datasource from './datasource'
import GlobalInputs from './global-inputs'
import Datasource from '../datasource'
import GlobalInputs from '../global-inputs'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock BlockIcon
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ type, toolIcon, className }: { type: BlockEnum, toolIcon?: string, className?: string }) => (
<div
@@ -24,12 +16,10 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
),
}))
// Mock useToolIcon
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon',
}))
// Mock Tooltip
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => (
<div data-testid="tooltip" data-content={popupContent} className={popupClassName} />
@@ -132,7 +122,6 @@ describe('Datasource', () => {
render(<Datasource nodeData={nodeData} />)
// Should still render without the title text
expect(screen.getByTestId('block-icon')).toBeInTheDocument()
})
@@ -160,13 +149,13 @@ describe('GlobalInputs', () => {
it('should render without crashing', () => {
render(<GlobalInputs />)
expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
})
it('should render title with correct translation key', () => {
render(<GlobalInputs />)
expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
})
it('should render tooltip component', () => {
@@ -179,7 +168,7 @@ describe('GlobalInputs', () => {
render(<GlobalInputs />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveAttribute('data-content', 'inputFieldPanel.globalInputs.tooltip')
expect(tooltip).toHaveAttribute('data-content', 'datasetPipeline.inputFieldPanel.globalInputs.tooltip')
})
it('should have correct tooltip className', () => {
@@ -199,7 +188,7 @@ describe('GlobalInputs', () => {
it('should have correct title styling', () => {
render(<GlobalInputs />)
const titleElement = screen.getByText('inputFieldPanel.globalInputs.title')
const titleElement = screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')
expect(titleElement).toHaveClass('system-sm-semibold-uppercase', 'text-text-secondary')
})
})

View File

@@ -3,15 +3,9 @@ import type { WorkflowRunningData } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { ChunkingMode } from '@/models/datasets'
import Header from './header'
// Import components after mocks
import TestRunPanel from './index'
import Header from '../header'
import TestRunPanel from '../index'
// ============================================================================
// Mocks
// ============================================================================
// Mock workflow store
const mockIsPreparingDataSource = vi.fn(() => true)
const mockSetIsPreparingDataSource = vi.fn()
const mockWorkflowRunningData = vi.fn<() => WorkflowRunningData | undefined>(() => undefined)
@@ -34,7 +28,6 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
// Mock workflow interactions
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
@@ -46,22 +39,18 @@ vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: () => 'mock-tool-icon',
}))
// Mock data source provider
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="data-source-provider">{children}</div>,
}))
// Mock Preparation component
vi.mock('./preparation', () => ({
vi.mock('../preparation', () => ({
default: () => <div data-testid="preparation-component">Preparation</div>,
}))
// Mock Result component (for TestRunPanel tests only)
vi.mock('./result', () => ({
vi.mock('../result', () => ({
default: () => <div data-testid="result-component">Result</div>,
}))
// Mock ResultPanel from workflow
vi.mock('@/app/components/workflow/run/result-panel', () => ({
default: (props: Record<string, unknown>) => (
<div data-testid="result-panel">
@@ -72,7 +61,6 @@ vi.mock('@/app/components/workflow/run/result-panel', () => ({
),
}))
// Mock TracingPanel from workflow
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: (props: { list: unknown[] }) => (
<div data-testid="tracing-panel">
@@ -85,20 +73,14 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
),
}))
// Mock Loading component
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">Loading...</div>,
}))
// Mock config
vi.mock('@/config', () => ({
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 5,
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockWorkflowRunningData = (overrides: Partial<WorkflowRunningData> = {}): WorkflowRunningData => ({
result: {
status: WorkflowRunningStatus.Succeeded,
@@ -141,10 +123,6 @@ const createMockQAOutputs = () => ({
],
})
// ============================================================================
// TestRunPanel Component Tests
// ============================================================================
describe('TestRunPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -152,7 +130,6 @@ describe('TestRunPanel', () => {
mockWorkflowRunningData.mockReturnValue(undefined)
})
// Basic rendering tests
describe('Rendering', () => {
it('should render with correct container styles', () => {
const { container } = render(<TestRunPanel />)
@@ -168,7 +145,6 @@ describe('TestRunPanel', () => {
})
})
// Conditional rendering based on isPreparingDataSource
describe('Conditional Content Rendering', () => {
it('should render Preparation inside DataSourceProvider when isPreparingDataSource is true', () => {
mockIsPreparingDataSource.mockReturnValue(true)
@@ -192,17 +168,12 @@ describe('TestRunPanel', () => {
})
})
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPreparingDataSource.mockReturnValue(true)
})
// Rendering tests
describe('Rendering', () => {
it('should render title with correct translation key', () => {
render(<Header />)
@@ -225,7 +196,6 @@ describe('Header', () => {
})
})
// Close button interactions
describe('Close Button Interaction', () => {
it('should call setIsPreparingDataSource(false) and handleCancelDebugAndPreviewPanel when clicked and isPreparingDataSource is true', () => {
mockIsPreparingDataSource.mockReturnValue(true)
@@ -253,19 +223,13 @@ describe('Header', () => {
})
})
// ============================================================================
// Result Component Tests (Real Implementation)
// ============================================================================
// Unmock Result for these tests
vi.doUnmock('./result')
vi.doUnmock('../result')
describe('Result', () => {
// Dynamically import Result to get real implementation
let Result: typeof import('./result').default
let Result: typeof import('../result').default
beforeAll(async () => {
const resultModule = await import('./result')
const resultModule = await import('../result')
Result = resultModule.default
})
@@ -274,7 +238,6 @@ describe('Result', () => {
mockWorkflowRunningData.mockReturnValue(undefined)
})
// Rendering tests
describe('Rendering', () => {
it('should render with RESULT tab active by default', async () => {
render(<Result />)
@@ -294,7 +257,6 @@ describe('Result', () => {
})
})
// Tab switching tests
describe('Tab Switching', () => {
it('should switch to DETAIL tab when clicked', async () => {
mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData())
@@ -321,7 +283,6 @@ describe('Result', () => {
})
})
// Loading states
describe('Loading States', () => {
it('should show loading in DETAIL tab when no result data', async () => {
mockWorkflowRunningData.mockReturnValue({
@@ -352,18 +313,13 @@ describe('Result', () => {
})
})
// ============================================================================
// ResultPreview Component Tests
// ============================================================================
// We need to import ResultPreview directly
vi.doUnmock('./result/result-preview')
vi.doUnmock('../result/result-preview')
describe('ResultPreview', () => {
let ResultPreview: typeof import('./result/result-preview').default
let ResultPreview: typeof import('../result/result-preview').default
beforeAll(async () => {
const previewModule = await import('./result/result-preview')
const previewModule = await import('../result/result-preview')
ResultPreview = previewModule.default
})
@@ -373,7 +329,6 @@ describe('ResultPreview', () => {
vi.clearAllMocks()
})
// Loading state
describe('Loading State', () => {
it('should show loading spinner when isRunning is true and no outputs', () => {
render(
@@ -402,7 +357,6 @@ describe('ResultPreview', () => {
})
})
// Error state
describe('Error State', () => {
it('should show error message when not running and has error', () => {
render(
@@ -448,7 +402,6 @@ describe('ResultPreview', () => {
})
})
// Success state with outputs
describe('Success State with Outputs', () => {
it('should render chunk content when outputs are available', () => {
render(
@@ -460,7 +413,6 @@ describe('ResultPreview', () => {
/>,
)
// Check that chunk content is rendered (the real ChunkCardList renders the content)
expect(screen.getByText('test chunk content')).toBeInTheDocument()
})
@@ -492,7 +444,6 @@ describe('ResultPreview', () => {
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty outputs gracefully', () => {
render(
@@ -504,7 +455,6 @@ describe('ResultPreview', () => {
/>,
)
// Should not crash and should not show chunk card list
expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument()
})
@@ -523,17 +473,13 @@ describe('ResultPreview', () => {
})
})
// ============================================================================
// Tabs Component Tests
// ============================================================================
vi.doUnmock('./result/tabs')
vi.doUnmock('../result/tabs')
describe('Tabs', () => {
let Tabs: typeof import('./result/tabs').default
let Tabs: typeof import('../result/tabs').default
beforeAll(async () => {
const tabsModule = await import('./result/tabs')
const tabsModule = await import('../result/tabs')
Tabs = tabsModule.default
})
@@ -543,7 +489,6 @@ describe('Tabs', () => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render all three tabs', () => {
render(
@@ -560,7 +505,6 @@ describe('Tabs', () => {
})
})
// Active tab styling
describe('Active Tab Styling', () => {
it('should highlight RESULT tab when currentTab is RESULT', () => {
render(
@@ -589,7 +533,6 @@ describe('Tabs', () => {
})
})
// Tab click handling
describe('Tab Click Handling', () => {
it('should call switchTab with RESULT when RESULT tab is clicked', () => {
render(
@@ -634,7 +577,6 @@ describe('Tabs', () => {
})
})
// Disabled state when no data
describe('Disabled State', () => {
it('should disable tabs when workflowRunningData is undefined', () => {
render(
@@ -651,17 +593,13 @@ describe('Tabs', () => {
})
})
// ============================================================================
// Tab Component Tests
// ============================================================================
vi.doUnmock('./result/tabs/tab')
vi.doUnmock('../result/tabs/tab')
describe('Tab', () => {
let Tab: typeof import('./result/tabs/tab').default
let Tab: typeof import('../result/tabs/tab').default
beforeAll(async () => {
const tabModule = await import('./result/tabs/tab')
const tabModule = await import('../result/tabs/tab')
Tab = tabModule.default
})
@@ -671,7 +609,6 @@ describe('Tab', () => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render tab with label', () => {
render(
@@ -688,7 +625,6 @@ describe('Tab', () => {
})
})
// Active state styling
describe('Active State', () => {
it('should have active styles when isActive is true', () => {
render(
@@ -721,7 +657,6 @@ describe('Tab', () => {
})
})
// Click handling
describe('Click Handling', () => {
it('should call onClick with value when clicked', () => {
render(
@@ -753,12 +688,10 @@ describe('Tab', () => {
const tab = screen.getByRole('button')
fireEvent.click(tab)
// The click handler is still called, but button is disabled
expect(tab).toBeDisabled()
})
})
// Disabled state
describe('Disabled State', () => {
it('should be disabled when workflowRunningData is undefined', () => {
render(
@@ -793,19 +726,14 @@ describe('Tab', () => {
})
})
// ============================================================================
// formatPreviewChunks Utility Tests
// ============================================================================
describe('formatPreviewChunks', () => {
let formatPreviewChunks: typeof import('./result/result-preview/utils').formatPreviewChunks
let formatPreviewChunks: typeof import('../result/result-preview/utils').formatPreviewChunks
beforeAll(async () => {
const utilsModule = await import('./result/result-preview/utils')
const utilsModule = await import('../result/result-preview/utils')
formatPreviewChunks = utilsModule.formatPreviewChunks
})
// Edge cases
describe('Edge Cases', () => {
it('should return undefined for null outputs', () => {
expect(formatPreviewChunks(null)).toBeUndefined()
@@ -824,7 +752,6 @@ describe('formatPreviewChunks', () => {
})
})
// General (text) chunks
describe('General Chunks (ChunkingMode.text)', () => {
it('should format general chunks correctly', () => {
const outputs = createMockGeneralOutputs(['content1', 'content2', 'content3'])
@@ -842,7 +769,6 @@ describe('formatPreviewChunks', () => {
const outputs = createMockGeneralOutputs(manyChunks)
const result = formatPreviewChunks(outputs) as GeneralChunks
// RAG_PIPELINE_PREVIEW_CHUNK_NUM is mocked to 5
expect(result).toHaveLength(5)
expect(result).toEqual([
{ content: 'chunk0', summary: undefined },
@@ -861,7 +787,6 @@ describe('formatPreviewChunks', () => {
})
})
// Parent-child chunks
describe('Parent-Child Chunks (ChunkingMode.parentChild)', () => {
it('should format paragraph mode parent-child chunks correctly', () => {
const outputs = createMockParentChildOutputs('paragraph')
@@ -902,7 +827,6 @@ describe('formatPreviewChunks', () => {
})
})
// QA chunks
describe('QA Chunks (ChunkingMode.qa)', () => {
it('should format QA chunks correctly', () => {
const outputs = createMockQAOutputs()
@@ -931,14 +855,10 @@ describe('formatPreviewChunks', () => {
})
})
// ============================================================================
// Types Tests
// ============================================================================
describe('Types', () => {
describe('TestRunStep Enum', () => {
it('should have correct enum values', async () => {
const { TestRunStep } = await import('./types')
const { TestRunStep } = await import('../types')
expect(TestRunStep.dataSource).toBe('dataSource')
expect(TestRunStep.documentProcessing).toBe('documentProcessing')

View File

@@ -0,0 +1,227 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDatasourceOptions, useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl } from '../hooks'
const mockNodes: Array<{ id: string, data: Partial<DataSourceNodeType> & { type: string } }> = []
vi.mock('reactflow', () => ({
useNodes: () => mockNodes,
}))
const mockDataSourceStoreGetState = vi.fn()
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
useDataSourceStore: () => ({
getState: mockDataSourceStoreGetState,
}),
}))
vi.mock('@/app/components/workflow/types', () => ({
BlockEnum: {
DataSource: 'data-source',
},
}))
vi.mock('../../types', () => ({
TestRunStep: {
dataSource: 'dataSource',
documentProcessing: 'documentProcessing',
},
}))
vi.mock('@/models/datasets', () => ({
CrawlStep: {
init: 'init',
},
}))
describe('useTestRunSteps', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('should initialize with step 1', () => {
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.currentStep).toBe(1)
})
it('should return 2 steps (dataSource and documentProcessing)', () => {
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.steps).toHaveLength(2)
expect(result.current.steps[0].value).toBe('dataSource')
expect(result.current.steps[1].value).toBe('documentProcessing')
})
it('should increment step on handleNextStep', () => {
const { result } = renderHook(() => useTestRunSteps())
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should decrement step on handleBackStep', () => {
const { result } = renderHook(() => useTestRunSteps())
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(1)
})
it('should have translated step labels', () => {
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.steps[0].label).toBeDefined()
expect(typeof result.current.steps[0].label).toBe('string')
})
})
describe('useDatasourceOptions', () => {
beforeEach(() => {
mockNodes.length = 0
vi.clearAllMocks()
})
it('should return empty options when no DataSource nodes', () => {
mockNodes.push({ id: 'n1', data: { type: BlockEnum.LLM, title: 'LLM' } })
const { result } = renderHook(() => useDatasourceOptions())
expect(result.current).toEqual([])
})
it('should return options from DataSource nodes', () => {
mockNodes.push(
{ id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source A' } },
{ id: 'ds-2', data: { type: BlockEnum.DataSource, title: 'Source B' } },
)
const { result } = renderHook(() => useDatasourceOptions())
expect(result.current).toHaveLength(2)
expect(result.current[0]).toEqual({
label: 'Source A',
value: 'ds-1',
data: expect.objectContaining({ type: 'data-source' }),
})
expect(result.current[1]).toEqual({
label: 'Source B',
value: 'ds-2',
data: expect.objectContaining({ type: 'data-source' }),
})
})
it('should filter out non-DataSource nodes', () => {
mockNodes.push(
{ id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source' } },
{ id: 'llm-1', data: { type: BlockEnum.LLM, title: 'LLM' } },
{ id: 'end-1', data: { type: BlockEnum.End, title: 'End' } },
)
const { result } = renderHook(() => useDatasourceOptions())
expect(result.current).toHaveLength(1)
expect(result.current[0].value).toBe('ds-1')
})
})
describe('useOnlineDocument', () => {
it('should clear all online document data', () => {
const mockSetDocumentsData = vi.fn()
const mockSetSearchValue = vi.fn()
const mockSetSelectedPagesId = vi.fn()
const mockSetOnlineDocuments = vi.fn()
const mockSetCurrentDocument = vi.fn()
mockDataSourceStoreGetState.mockReturnValue({
setDocumentsData: mockSetDocumentsData,
setSearchValue: mockSetSearchValue,
setSelectedPagesId: mockSetSelectedPagesId,
setOnlineDocuments: mockSetOnlineDocuments,
setCurrentDocument: mockSetCurrentDocument,
})
const { result } = renderHook(() => useOnlineDocument())
act(() => {
result.current.clearOnlineDocumentData()
})
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
expect(mockSetSearchValue).toHaveBeenCalledWith('')
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(new Set())
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
})
})
describe('useWebsiteCrawl', () => {
it('should clear all website crawl data', () => {
const mockSetStep = vi.fn()
const mockSetCrawlResult = vi.fn()
const mockSetWebsitePages = vi.fn()
const mockSetPreviewIndex = vi.fn()
const mockSetCurrentWebsite = vi.fn()
mockDataSourceStoreGetState.mockReturnValue({
setStep: mockSetStep,
setCrawlResult: mockSetCrawlResult,
setWebsitePages: mockSetWebsitePages,
setPreviewIndex: mockSetPreviewIndex,
setCurrentWebsite: mockSetCurrentWebsite,
})
const { result } = renderHook(() => useWebsiteCrawl())
act(() => {
result.current.clearWebsiteCrawlData()
})
expect(mockSetStep).toHaveBeenCalledWith('init')
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
})
})
describe('useOnlineDrive', () => {
it('should clear all online drive data', () => {
const mockSetOnlineDriveFileList = vi.fn()
const mockSetBucket = vi.fn()
const mockSetPrefix = vi.fn()
const mockSetKeywords = vi.fn()
const mockSetSelectedFileIds = vi.fn()
mockDataSourceStoreGetState.mockReturnValue({
setOnlineDriveFileList: mockSetOnlineDriveFileList,
setBucket: mockSetBucket,
setPrefix: mockSetPrefix,
setKeywords: mockSetKeywords,
setSelectedFileIds: mockSetSelectedFileIds,
})
const { result } = renderHook(() => useOnlineDrive())
act(() => {
result.current.clearOnlineDriveData()
})
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
expect(mockSetBucket).toHaveBeenCalledWith('')
expect(mockSetPrefix).toHaveBeenCalledWith([])
expect(mockSetKeywords).toHaveBeenCalledWith('')
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
})
})

View File

@@ -1,49 +1,33 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Actions from './index'
// ============================================================================
// Actions Component Tests
// ============================================================================
import Actions from '../index'
describe('Actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render button with translated text', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
// Assert - Translation mock returns key with namespace prefix
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render with correct container structure', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { container } = render(<Actions handleNextStep={handleNextStep} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('flex')
expect(wrapper.className).toContain('justify-end')
@@ -52,197 +36,143 @@ describe('Actions', () => {
})
it('should render span with px-0.5 class around text', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { container } = render(<Actions handleNextStep={handleNextStep} />)
// Assert
const span = container.querySelector('span')
expect(span).toBeInTheDocument()
expect(span?.className).toContain('px-0.5')
})
})
// -------------------------------------------------------------------------
// Props Variations Tests
// -------------------------------------------------------------------------
describe('Props Variations', () => {
it('should pass disabled=true to button when disabled prop is true', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should pass disabled=false to button when disabled prop is false', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should not disable button when disabled prop is undefined', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should handle disabled switching from true to false', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={true} handleNextStep={handleNextStep} />,
)
// Assert - Initially disabled
expect(screen.getByRole('button')).toBeDisabled()
// Act - Rerender with disabled=false
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert - Now enabled
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should handle disabled switching from false to true', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Assert - Initially enabled
expect(screen.getByRole('button')).not.toBeDisabled()
// Act - Rerender with disabled=true
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Now disabled
expect(screen.getByRole('button')).toBeDisabled()
})
it('should handle undefined disabled becoming true', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep} />,
)
// Assert - Initially not disabled (undefined)
expect(screen.getByRole('button')).not.toBeDisabled()
// Act - Rerender with disabled=true
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Now disabled
expect(screen.getByRole('button')).toBeDisabled()
})
})
// -------------------------------------------------------------------------
// User Interaction Tests
// -------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call handleNextStep when button is clicked', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
})
it('should call handleNextStep exactly once per click', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalled()
expect(handleNextStep.mock.calls).toHaveLength(1)
})
it('should call handleNextStep multiple times on multiple clicks', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(3)
})
it('should not call handleNextStep when button is disabled and clicked', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert - Disabled button should not trigger onClick
expect(handleNextStep).not.toHaveBeenCalled()
})
it('should handle rapid clicks when not disabled', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
const button = screen.getByRole('button')
// Simulate rapid clicks
for (let i = 0; i < 10; i++)
fireEvent.click(button)
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(10)
})
})
// -------------------------------------------------------------------------
// Callback Stability Tests
// -------------------------------------------------------------------------
describe('Callback Stability', () => {
it('should use the new handleNextStep when prop changes', () => {
// Arrange
const handleNextStep1 = vi.fn()
const handleNextStep2 = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep1} />,
)
@@ -251,16 +181,13 @@ describe('Actions', () => {
rerender(<Actions handleNextStep={handleNextStep2} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep1).toHaveBeenCalledTimes(1)
expect(handleNextStep2).toHaveBeenCalledTimes(1)
})
it('should maintain functionality after rerender with same props', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep} />,
)
@@ -269,17 +196,14 @@ describe('Actions', () => {
rerender(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(2)
})
it('should work correctly when handleNextStep changes multiple times', () => {
// Arrange
const handleNextStep1 = vi.fn()
const handleNextStep2 = vi.fn()
const handleNextStep3 = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep1} />,
)
@@ -291,77 +215,58 @@ describe('Actions', () => {
rerender(<Actions handleNextStep={handleNextStep3} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep1).toHaveBeenCalledTimes(1)
expect(handleNextStep2).toHaveBeenCalledTimes(1)
expect(handleNextStep3).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
// Memoization Tests
// -------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Arrange
const handleNextStep = vi.fn()
// Act - Verify component is memoized by checking display name pattern
const { rerender } = render(
<Actions handleNextStep={handleNextStep} />,
)
// Rerender with same props should work without issues
rerender(<Actions handleNextStep={handleNextStep} />)
// Assert - Component should render correctly after rerender
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not break when props remain the same across rerenders', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Multiple rerenders with same props
for (let i = 0; i < 5; i++) {
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
}
// Assert - Should still function correctly
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(1)
})
it('should update correctly when only disabled prop changes', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Assert - Initially not disabled
expect(screen.getByRole('button')).not.toBeDisabled()
// Act - Change only disabled prop
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Should reflect the new disabled state
expect(screen.getByRole('button')).toBeDisabled()
})
it('should update correctly when only handleNextStep prop changes', () => {
// Arrange
const handleNextStep1 = vi.fn()
const handleNextStep2 = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep1} />,
)
@@ -369,169 +274,124 @@ describe('Actions', () => {
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep1).toHaveBeenCalledTimes(1)
// Act - Change only handleNextStep prop
rerender(<Actions disabled={false} handleNextStep={handleNextStep2} />)
fireEvent.click(screen.getByRole('button'))
// Assert - New callback should be used
expect(handleNextStep1).toHaveBeenCalledTimes(1)
expect(handleNextStep2).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
// Edge Cases Tests
// -------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should call handleNextStep even if it has side effects', () => {
// Arrange
let sideEffectValue = 0
const handleNextStep = vi.fn(() => {
sideEffectValue = 42
})
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
expect(sideEffectValue).toBe(42)
})
it('should handle handleNextStep that returns a value', () => {
// Arrange
const handleNextStep = vi.fn(() => 'return value')
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
expect(handleNextStep).toHaveReturnedWith('return value')
})
it('should handle handleNextStep that is async', async () => {
// Arrange
const handleNextStep = vi.fn().mockResolvedValue(undefined)
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
})
it('should render correctly with both disabled=true and handleNextStep', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('should handle component unmount gracefully', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { unmount } = render(<Actions handleNextStep={handleNextStep} />)
// Assert - Unmount should not throw
expect(() => unmount()).not.toThrow()
})
it('should handle disabled as boolean-like falsy value', () => {
// Arrange
const handleNextStep = vi.fn()
// Act - Test with explicit false
render(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
// -------------------------------------------------------------------------
// Accessibility Tests
// -------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have button element that can receive focus', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
const button = screen.getByRole('button')
// Assert - Button should be focusable (not disabled by default)
expect(button).not.toBeDisabled()
})
it('should indicate disabled state correctly', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).toHaveAttribute('disabled')
})
})
// -------------------------------------------------------------------------
// Integration Tests
// -------------------------------------------------------------------------
describe('Integration', () => {
it('should work in a typical workflow: enable -> click -> disable', () => {
// Arrange
const handleNextStep = vi.fn()
// Act - Start enabled
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Assert - Can click when enabled
expect(screen.getByRole('button')).not.toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(1)
// Act - Disable after click (simulating loading state)
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Cannot click when disabled
expect(screen.getByRole('button')).toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(1) // Still 1, not 2
// Act - Re-enable
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert - Can click again
expect(screen.getByRole('button')).not.toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(2)
})
it('should maintain consistent rendering across multiple state changes', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Toggle disabled state multiple times
const states = [true, false, true, false, true]
states.forEach((disabled) => {
rerender(<Actions disabled={disabled} handleNextStep={handleNextStep} />)
@@ -541,7 +401,6 @@ describe('Actions', () => {
expect(screen.getByRole('button')).not.toBeDisabled()
})
// Assert - Button should still render correctly
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})

View File

@@ -5,41 +5,19 @@ import * as React from 'react'
import { BlockEnum, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { RAG_PIPELINE_PREVIEW_CHUNK_NUM } from '@/config'
import { ChunkingMode } from '@/models/datasets'
import Result from './index'
import ResultPreview from './result-preview'
import { formatPreviewChunks } from './result-preview/utils'
import Tabs from './tabs'
import Tab from './tabs/tab'
// ============================================================================
// Pre-declare variables used in mocks (hoisting)
// ============================================================================
import Result from '../index'
import ResultPreview from '../result-preview'
import { formatPreviewChunks } from '../result-preview/utils'
import Tabs from '../tabs'
import Tab from '../tabs/tab'
let mockWorkflowRunningData: WorkflowRunningData | undefined
// ============================================================================
// Mock External Dependencies
// ============================================================================
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, count?: number }) => {
const ns = options?.ns ? `${options.ns}.` : ''
if (options?.count !== undefined)
return `${ns}${key} (count: ${options.count})`
return `${ns}${key}`
},
}),
}))
// Mock workflow store
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: { workflowRunningData: WorkflowRunningData | undefined }) => T) =>
selector({ workflowRunningData: mockWorkflowRunningData }),
}))
// Mock child components
vi.mock('@/app/components/workflow/run/result-panel', () => ({
default: ({
inputs,
@@ -102,10 +80,6 @@ vi.mock('@/app/components/rag-pipeline/components/chunk-card-list', () => ({
),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockWorkflowRunningData = (
overrides?: Partial<WorkflowRunningData>,
): WorkflowRunningData => ({
@@ -191,26 +165,15 @@ const createQAChunkOutputs = (qaCount: number = 5) => ({
})),
})
// ============================================================================
// Helper Functions
// ============================================================================
const resetAllMocks = () => {
mockWorkflowRunningData = undefined
}
// ============================================================================
// Tab Component Tests
// ============================================================================
describe('Tab', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render tab with label', () => {
const mockOnClick = vi.fn()
@@ -283,9 +246,6 @@ describe('Tab', () => {
})
})
// -------------------------------------------------------------------------
// User Interaction Tests
// -------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClick with value when clicked', () => {
const mockOnClick = vi.fn()
@@ -325,9 +285,6 @@ describe('Tab', () => {
})
})
// -------------------------------------------------------------------------
// Memoization Tests
// -------------------------------------------------------------------------
describe('Memoization', () => {
it('should maintain stable handleClick callback reference', () => {
const mockOnClick = vi.fn()
@@ -353,33 +310,26 @@ describe('Tab', () => {
})
})
// -------------------------------------------------------------------------
// Props Variation Tests
// -------------------------------------------------------------------------
describe('Props Variations', () => {
it('should render with all combinations of isActive and workflowRunningData', () => {
const mockOnClick = vi.fn()
const workflowData = createMockWorkflowRunningData()
// Active with data
const { rerender } = render(
<Tab isActive={true} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />,
)
expect(screen.getByRole('button')).not.toBeDisabled()
// Inactive with data
rerender(
<Tab isActive={false} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />,
)
expect(screen.getByRole('button')).not.toBeDisabled()
// Active without data
rerender(
<Tab isActive={true} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />,
)
expect(screen.getByRole('button')).toBeDisabled()
// Inactive without data
rerender(
<Tab isActive={false} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />,
)
@@ -388,18 +338,11 @@ describe('Tab', () => {
})
})
// ============================================================================
// Tabs Component Tests
// ============================================================================
describe('Tabs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render all three tabs', () => {
render(
@@ -440,18 +383,12 @@ describe('Tabs', () => {
)
const buttons = screen.getAllByRole('button')
// RESULT tab
expect(buttons[0]).toHaveClass('border-transparent')
// DETAIL tab (active)
expect(buttons[1]).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
// TRACING tab
expect(buttons[2]).toHaveClass('border-transparent')
})
})
// -------------------------------------------------------------------------
// User Interaction Tests
// -------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call switchTab when RESULT tab is clicked', () => {
const mockSwitchTab = vi.fn()
@@ -522,9 +459,6 @@ describe('Tabs', () => {
})
})
// -------------------------------------------------------------------------
// Props Variation Tests
// -------------------------------------------------------------------------
describe('Props Variations', () => {
it('should handle all currentTab values', () => {
const mockSwitchTab = vi.fn()
@@ -554,14 +488,7 @@ describe('Tabs', () => {
})
})
// ============================================================================
// formatPreviewChunks Utility Tests
// ============================================================================
describe('formatPreviewChunks', () => {
// -------------------------------------------------------------------------
// Edge Cases Tests
// -------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should return undefined when outputs is null', () => {
expect(formatPreviewChunks(null)).toBeUndefined()
@@ -581,9 +508,6 @@ describe('formatPreviewChunks', () => {
})
})
// -------------------------------------------------------------------------
// General Chunks Tests
// -------------------------------------------------------------------------
describe('General Chunks (text mode)', () => {
it('should format general chunks correctly', () => {
const outputs = createGeneralChunkOutputs(3)
@@ -613,9 +537,6 @@ describe('formatPreviewChunks', () => {
})
})
// -------------------------------------------------------------------------
// Parent-Child Chunks Tests
// -------------------------------------------------------------------------
describe('Parent-Child Chunks (hierarchical mode)', () => {
it('should format paragraph mode chunks correctly', () => {
const outputs = createParentChildChunkOutputs('paragraph', 3)
@@ -678,9 +599,6 @@ describe('formatPreviewChunks', () => {
})
})
// -------------------------------------------------------------------------
// QA Chunks Tests
// -------------------------------------------------------------------------
describe('QA Chunks (qa mode)', () => {
it('should format QA chunks correctly', () => {
const outputs = createQAChunkOutputs(3)
@@ -710,18 +628,11 @@ describe('formatPreviewChunks', () => {
})
})
// ============================================================================
// ResultPreview Component Tests
// ============================================================================
describe('ResultPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render loading state when isRunning is true and no outputs', () => {
render(
@@ -778,7 +689,7 @@ describe('ResultPreview', () => {
)
expect(
screen.getByText(`pipeline.result.resultPreview.footerTip (count: ${RAG_PIPELINE_PREVIEW_CHUNK_NUM})`),
screen.getByText(`pipeline.result.resultPreview.footerTip:{"count":${RAG_PIPELINE_PREVIEW_CHUNK_NUM}}`),
).toBeInTheDocument()
})
@@ -799,9 +710,6 @@ describe('ResultPreview', () => {
})
})
// -------------------------------------------------------------------------
// User Interaction Tests
// -------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onSwitchToDetail when view details button is clicked', () => {
const mockOnSwitchToDetail = vi.fn()
@@ -821,9 +729,6 @@ describe('ResultPreview', () => {
})
})
// -------------------------------------------------------------------------
// Props Variation Tests
// -------------------------------------------------------------------------
describe('Props Variations', () => {
it('should render with general chunks output', () => {
const outputs = createGeneralChunkOutputs(3)
@@ -874,9 +779,6 @@ describe('ResultPreview', () => {
})
})
// -------------------------------------------------------------------------
// Edge Cases Tests
// -------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle outputs with no previewChunks result', () => {
const outputs = {
@@ -893,7 +795,6 @@ describe('ResultPreview', () => {
/>,
)
// Should not render chunk card list when formatPreviewChunks returns undefined
expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument()
})
@@ -907,14 +808,10 @@ describe('ResultPreview', () => {
/>,
)
// Error section should not render when isRunning is true
expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// Memoization Tests
// -------------------------------------------------------------------------
describe('Memoization', () => {
it('should memoize previewChunks calculation', () => {
const outputs = createGeneralChunkOutputs(3)
@@ -927,7 +824,6 @@ describe('ResultPreview', () => {
/>,
)
// Re-render with same outputs - should use memoized value
rerender(
<ResultPreview
isRunning={false}
@@ -942,19 +838,12 @@ describe('ResultPreview', () => {
})
})
// ============================================================================
// Result Component Tests (Main Component)
// ============================================================================
describe('Result', () => {
beforeEach(() => {
vi.clearAllMocks()
resetAllMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render tabs and result preview by default', () => {
mockWorkflowRunningData = createMockWorkflowRunningData({
@@ -967,7 +856,6 @@ describe('Result', () => {
render(<Result />)
// Tabs should be rendered
expect(screen.getByText('runLog.result')).toBeInTheDocument()
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
expect(screen.getByText('runLog.tracing')).toBeInTheDocument()
@@ -1003,9 +891,6 @@ describe('Result', () => {
})
})
// -------------------------------------------------------------------------
// Tab Switching Tests
// -------------------------------------------------------------------------
describe('Tab Switching', () => {
it('should switch to DETAIL tab when clicked', async () => {
mockWorkflowRunningData = createMockWorkflowRunningData()
@@ -1042,13 +927,11 @@ describe('Result', () => {
render(<Result />)
// Switch to DETAIL
fireEvent.click(screen.getByText('runLog.detail'))
await waitFor(() => {
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
// Switch back to RESULT
fireEvent.click(screen.getByText('runLog.result'))
await waitFor(() => {
expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument()
@@ -1056,9 +939,6 @@ describe('Result', () => {
})
})
// -------------------------------------------------------------------------
// DETAIL Tab Content Tests
// -------------------------------------------------------------------------
describe('DETAIL Tab Content', () => {
it('should render ResultPanel with correct props', async () => {
mockWorkflowRunningData = createMockWorkflowRunningData({
@@ -1109,9 +989,6 @@ describe('Result', () => {
})
})
// -------------------------------------------------------------------------
// TRACING Tab Content Tests
// -------------------------------------------------------------------------
describe('TRACING Tab Content', () => {
it('should render TracingPanel with tracing data', async () => {
mockWorkflowRunningData = createMockWorkflowRunningData()
@@ -1137,15 +1014,11 @@ describe('Result', () => {
fireEvent.click(screen.getByText('runLog.tracing'))
await waitFor(() => {
// Both TracingPanel and Loading should be rendered
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Switch to Detail from Result Preview Tests
// -------------------------------------------------------------------------
describe('Switch to Detail from Result Preview', () => {
it('should switch to DETAIL tab when onSwitchToDetail is triggered from ResultPreview', async () => {
mockWorkflowRunningData = createMockWorkflowRunningData({
@@ -1159,7 +1032,6 @@ describe('Result', () => {
render(<Result />)
// Click the view details button in error state
fireEvent.click(screen.getByText('pipeline.result.resultPreview.viewDetails'))
await waitFor(() => {
@@ -1168,16 +1040,12 @@ describe('Result', () => {
})
})
// -------------------------------------------------------------------------
// Edge Cases Tests
// -------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle undefined workflowRunningData', () => {
mockWorkflowRunningData = undefined
render(<Result />)
// All tabs should be disabled
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button).toBeDisabled()
@@ -1193,7 +1061,6 @@ describe('Result', () => {
render(<Result />)
// Should show loading in RESULT tab (isRunning condition)
expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument()
})
@@ -1223,36 +1090,28 @@ describe('Result', () => {
render(<Result />)
// Should show error when stopped
expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// State Management Tests
// -------------------------------------------------------------------------
describe('State Management', () => {
it('should maintain tab state across re-renders', async () => {
mockWorkflowRunningData = createMockWorkflowRunningData()
const { rerender } = render(<Result />)
// Switch to DETAIL tab
fireEvent.click(screen.getByText('runLog.detail'))
await waitFor(() => {
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
// Re-render component
rerender(<Result />)
// Should still be on DETAIL tab
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
it('should render different states based on workflowRunningData', () => {
// Test 1: Running state with no outputs
mockWorkflowRunningData = createMockWorkflowRunningData({
result: {
...createMockWorkflowRunningData().result,
@@ -1265,7 +1124,6 @@ describe('Result', () => {
expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument()
unmount()
// Test 2: Completed state with outputs
const outputs = createGeneralChunkOutputs(3)
mockWorkflowRunningData = createMockWorkflowRunningData({
result: {
@@ -1280,19 +1138,14 @@ describe('Result', () => {
})
})
// -------------------------------------------------------------------------
// Memoization Tests
// -------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized', () => {
mockWorkflowRunningData = createMockWorkflowRunningData()
const { rerender } = render(<Result />)
// Re-render without changes
rerender(<Result />)
// Component should still be rendered correctly
expect(screen.getByText('runLog.result')).toBeInTheDocument()
})
})

View File

@@ -3,21 +3,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
// ============================================================================
// Import Components After Mocks
// ============================================================================
import RagPipelineHeader from '../index'
import InputFieldButton from '../input-field-button'
import Publisher from '../publisher'
import Popup from '../publisher/popup'
import RunMode from '../run-mode'
import RagPipelineHeader from './index'
import InputFieldButton from './input-field-button'
import Publisher from './publisher'
import Popup from './publisher/popup'
import RunMode from './run-mode'
// ============================================================================
// Mock External Dependencies
// ============================================================================
// Mock workflow store
const mockSetShowInputFieldPanel = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetIsPreparingDataSource = vi.fn()
@@ -51,7 +42,6 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
// Mock workflow hooks
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
const mockHandleStopRun = vi.fn()
@@ -72,7 +62,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}),
}))
// Mock Header component
vi.mock('@/app/components/workflow/header', () => ({
default: ({ normal, viewHistory }: {
normal?: { components?: { left?: ReactNode, middle?: ReactNode }, runAndHistoryProps?: unknown }
@@ -87,21 +76,18 @@ vi.mock('@/app/components/workflow/header', () => ({
),
}))
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
useRouter: () => ({ push: mockPush }),
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: PropsWithChildren<{ href: string }>) => (
<a href={href} {...props}>{children}</a>
),
}))
// Mock service hooks
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: Date.now() })
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
@@ -127,7 +113,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => vi.fn(),
}))
// Mock context hooks
const mockMutateDatasetRes = vi.fn()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: () => mockMutateDatasetRes,
@@ -145,7 +130,6 @@ vi.mock('@/context/provider-context', () => ({
selector(mockProviderContextValue),
}))
// Mock event emitter context
const mockEventEmitter = {
useSubscription: vi.fn(),
}
@@ -156,7 +140,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
// Mock hooks
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => '/api/docs',
}))
@@ -167,12 +150,10 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
}),
}))
// Mock amplitude tracking
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
@@ -180,13 +161,11 @@ vi.mock('@/app/components/base/toast', () => ({
}),
}))
// Mock workflow utils
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: (key: string) => key,
getKeyboardKeyNameBySystem: (key: string) => key,
}))
// Mock ahooks
vi.mock('ahooks', () => ({
useBoolean: (initial: boolean) => {
let value = initial
@@ -202,7 +181,6 @@ vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
// Mock portal components - keep actual behavior for open state
let portalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{
@@ -224,8 +202,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
},
}))
// Mock PublishAsKnowledgePipelineModal
vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
default: ({ onConfirm, onCancel }: {
onConfirm: (name: string, icon: unknown, description?: string) => void
onCancel: () => void
@@ -238,10 +215,6 @@ vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({
),
}))
// ============================================================================
// Test Suites
// ============================================================================
describe('RagPipelineHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -259,9 +232,6 @@ describe('RagPipelineHeader', () => {
mockProviderContextValue = createMockProviderContextValue()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<RagPipelineHeader />)
@@ -286,19 +256,14 @@ describe('RagPipelineHeader', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should compute viewHistoryProps based on pipelineId', () => {
// Test with first pipelineId
mockStoreState.pipelineId = 'pipeline-alpha'
const { unmount } = render(<RagPipelineHeader />)
let viewHistoryContent = screen.getByTestId('header-view-history').textContent
expect(viewHistoryContent).toContain('pipeline-alpha')
unmount()
// Test with different pipelineId
mockStoreState.pipelineId = 'pipeline-beta'
render(<RagPipelineHeader />)
viewHistoryContent = screen.getByTestId('header-view-history').textContent
@@ -320,9 +285,6 @@ describe('InputFieldButton', () => {
mockStoreState.setShowEnvPanel = mockSetShowEnvPanel
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render button with correct text', () => {
render(<InputFieldButton />)
@@ -337,9 +299,6 @@ describe('InputFieldButton', () => {
})
})
// --------------------------------------------------------------------------
// Event Handler Tests
// --------------------------------------------------------------------------
describe('Event Handlers', () => {
it('should call setShowInputFieldPanel(true) when clicked', () => {
render(<InputFieldButton />)
@@ -367,16 +326,12 @@ describe('InputFieldButton', () => {
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle undefined setShowInputFieldPanel gracefully', () => {
mockStoreState.setShowInputFieldPanel = undefined as unknown as typeof mockSetShowInputFieldPanel
render(<InputFieldButton />)
// Should not throw when clicked
expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow()
})
})
@@ -388,9 +343,6 @@ describe('Publisher', () => {
portalOpenState = false
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render publish button', () => {
render(<Publisher />)
@@ -410,9 +362,6 @@ describe('Publisher', () => {
})
})
// --------------------------------------------------------------------------
// Interaction Tests
// --------------------------------------------------------------------------
describe('Interactions', () => {
it('should call handleSyncWorkflowDraft when opening', () => {
render(<Publisher />)
@@ -430,7 +379,6 @@ describe('Publisher', () => {
fireEvent.click(screen.getByTestId('portal-trigger'))
// After click, handleOpenChange should be called
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
})
})
@@ -447,9 +395,6 @@ describe('Popup', () => {
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render popup container', () => {
render(<Popup />)
@@ -475,7 +420,6 @@ describe('Popup', () => {
it('should render keyboard shortcuts', () => {
render(<Popup />)
// Should show the keyboard shortcut keys
expect(screen.getByText('ctrl')).toBeInTheDocument()
expect(screen.getByText('⇧')).toBeInTheDocument()
expect(screen.getByText('P')).toBeInTheDocument()
@@ -500,9 +444,6 @@ describe('Popup', () => {
})
})
// --------------------------------------------------------------------------
// Button State Tests
// --------------------------------------------------------------------------
describe('Button States', () => {
it('should disable goToAddDocuments when not published', () => {
mockStoreState.publishedAt = 0
@@ -532,9 +473,6 @@ describe('Popup', () => {
})
})
// --------------------------------------------------------------------------
// Premium Badge Tests
// --------------------------------------------------------------------------
describe('Premium Badge', () => {
it('should show premium badge when not allowed to publish as template', () => {
mockProviderContextValue = createMockProviderContextValue({
@@ -557,9 +495,6 @@ describe('Popup', () => {
})
})
// --------------------------------------------------------------------------
// Interaction Tests
// --------------------------------------------------------------------------
describe('Interactions', () => {
it('should call handleCheckBeforePublish when publish button clicked', async () => {
render(<Popup />)
@@ -598,9 +533,6 @@ describe('Popup', () => {
})
})
// --------------------------------------------------------------------------
// Auto-save Display Tests
// --------------------------------------------------------------------------
describe('Auto-save Display', () => {
it('should show auto-saved time when not published', () => {
mockStoreState.publishedAt = 0
@@ -629,9 +561,6 @@ describe('RunMode', () => {
mockEventEmitterEnabled = true
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render run button with default text', () => {
render(<RunMode />)
@@ -654,9 +583,6 @@ describe('RunMode', () => {
})
})
// --------------------------------------------------------------------------
// Running State Tests
// --------------------------------------------------------------------------
describe('Running States', () => {
it('should show processing state when running', () => {
mockStoreState.workflowRunningData = {
@@ -677,7 +603,6 @@ describe('RunMode', () => {
render(<RunMode />)
// There should be two buttons: run button and stop button
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBe(2)
})
@@ -751,7 +676,6 @@ describe('RunMode', () => {
render(<RunMode />)
// Should only have one button (run button)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBe(1)
})
@@ -781,9 +705,6 @@ describe('RunMode', () => {
})
})
// --------------------------------------------------------------------------
// Disabled State Tests
// --------------------------------------------------------------------------
describe('Disabled States', () => {
it('should be disabled when running', () => {
mockStoreState.workflowRunningData = {
@@ -818,9 +739,6 @@ describe('RunMode', () => {
})
})
// --------------------------------------------------------------------------
// Interaction Tests
// --------------------------------------------------------------------------
describe('Interactions', () => {
it('should call handleWorkflowStartRunInWorkflow when clicked', () => {
render(<RunMode />)
@@ -838,7 +756,6 @@ describe('RunMode', () => {
render(<RunMode />)
// Click the stop button (second button)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[1])
@@ -850,7 +767,6 @@ describe('RunMode', () => {
render(<RunMode />)
// Click the cancel button (second button)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[1])
@@ -883,14 +799,10 @@ describe('RunMode', () => {
const runButton = screen.getAllByRole('button')[0]
fireEvent.click(runButton)
// Should not be called because button is disabled
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Event Emitter Tests
// --------------------------------------------------------------------------
describe('Event Emitter', () => {
it('should subscribe to event emitter', () => {
render(<RunMode />)
@@ -904,7 +816,6 @@ describe('RunMode', () => {
result: { status: WorkflowRunningStatus.Running },
}
// Capture the subscription callback
let subscriptionCallback: ((v: { type: string }) => void) | null = null
mockEventEmitter.useSubscription.mockImplementation((callback: (v: { type: string }) => void) => {
subscriptionCallback = callback
@@ -912,7 +823,6 @@ describe('RunMode', () => {
render(<RunMode />)
// Simulate the EVENT_WORKFLOW_STOP event (actual value is 'WORKFLOW_STOP')
expect(subscriptionCallback).not.toBeNull()
subscriptionCallback!({ type: 'WORKFLOW_STOP' })
@@ -932,7 +842,6 @@ describe('RunMode', () => {
render(<RunMode />)
// Simulate a different event type
subscriptionCallback!({ type: 'some_other_event' })
expect(mockHandleStopRun).not.toHaveBeenCalled()
@@ -941,7 +850,6 @@ describe('RunMode', () => {
it('should handle undefined eventEmitter gracefully', () => {
mockEventEmitterEnabled = false
// Should not throw when eventEmitter is undefined
expect(() => render(<RunMode />)).not.toThrow()
})
@@ -951,14 +859,10 @@ describe('RunMode', () => {
render(<RunMode />)
// useSubscription should not be called
expect(mockEventEmitter.useSubscription).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Style Tests
// --------------------------------------------------------------------------
describe('Styles', () => {
it('should have rounded-md class when not disabled', () => {
render(<RunMode />)
@@ -1053,21 +957,13 @@ describe('RunMode', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped in React.memo', () => {
// RunMode is exported as default from run-mode.tsx with React.memo
// We can verify it's memoized by checking the component's $$typeof symbol
expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1087,10 +983,8 @@ describe('Integration', () => {
it('should render all child components in RagPipelineHeader', () => {
render(<RagPipelineHeader />)
// InputFieldButton
expect(screen.getByText(/inputField/i)).toBeInTheDocument()
// Publisher (via header-middle slot)
expect(screen.getByTestId('header-middle')).toBeInTheDocument()
})
@@ -1104,9 +998,6 @@ describe('Integration', () => {
})
})
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1136,20 +1027,17 @@ describe('Edge Cases', () => {
result: undefined as unknown as { status: WorkflowRunningStatus },
}
// Component will crash when accessing result.status - this documents current behavior
expect(() => render(<RunMode />)).toThrow()
})
})
describe('RunMode Edge Cases', () => {
beforeEach(() => {
// Ensure clean state for each test
mockStoreState.workflowRunningData = null
mockStoreState.isPreparingDataSource = false
})
it('should handle both isPreparingDataSource and isRunning being true', () => {
// This shouldn't happen in practice, but test the priority
mockStoreState.isPreparingDataSource = true
mockStoreState.workflowRunningData = {
task_id: 'task-123',
@@ -1158,7 +1046,6 @@ describe('Edge Cases', () => {
render(<RunMode />)
// Button should be disabled
const runButton = screen.getAllByRole('button')[0]
expect(runButton).toBeDisabled()
})
@@ -1169,7 +1056,6 @@ describe('Edge Cases', () => {
render(<RunMode />)
// Verify the button is enabled and shows testRun text
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
expect(button.textContent).toContain('pipeline.common.testRun')
@@ -1193,7 +1079,6 @@ describe('Edge Cases', () => {
render(<RunMode text="Start Pipeline" />)
// Should show reRun, not custom text
const button = screen.getByRole('button')
expect(button.textContent).toContain('pipeline.common.reRun')
expect(screen.queryByText('Start Pipeline')).not.toBeInTheDocument()
@@ -1205,7 +1090,6 @@ describe('Edge Cases', () => {
render(<RunMode />)
// Verify keyboard shortcut elements exist
expect(screen.getByText('alt')).toBeInTheDocument()
expect(screen.getByText('R')).toBeInTheDocument()
})
@@ -1216,7 +1100,6 @@ describe('Edge Cases', () => {
render(<RunMode />)
// Should have svg icon in the button
const button = screen.getByRole('button')
expect(button.querySelector('svg')).toBeInTheDocument()
})
@@ -1229,7 +1112,6 @@ describe('Edge Cases', () => {
render(<RunMode />)
// Should have animate-spin class on the loader icon
const runButton = screen.getAllByRole('button')[0]
const spinningIcon = runButton.querySelector('.animate-spin')
expect(spinningIcon).toBeInTheDocument()
@@ -1252,7 +1134,6 @@ describe('Edge Cases', () => {
render(<Popup />)
// Should render without crashing
expect(screen.getByText(/workflow.common.autoSaved/i)).toBeInTheDocument()
})

View File

@@ -0,0 +1,192 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RunMode from '../run-mode'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockSetIsPreparingDataSource = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
let mockWorkflowRunningData: { task_id: string, result: { status: string } } | undefined
let mockIsPreparingDataSource = false
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowRun: () => ({
handleStopRun: mockHandleStopRun,
}),
useWorkflowStartRun: () => ({
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
workflowRunningData: mockWorkflowRunningData,
isPreparingDataSource: mockIsPreparingDataSource,
}
return selector(state)
},
useWorkflowStore: () => ({
getState: () => ({
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
}),
}),
}))
vi.mock('@/app/components/workflow/types', () => ({
WorkflowRunningStatus: { Running: 'running' },
}))
vi.mock('@/app/components/workflow/variable-inspect/types', () => ({
EVENT_WORKFLOW_STOP: 'EVENT_WORKFLOW_STOP',
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: { useSubscription: vi.fn() },
}),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string').join(' '),
}))
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
RiDatabase2Line: () => <span data-testid="database-icon" />,
RiLoader2Line: () => <span data-testid="loader-icon" />,
RiPlayLargeLine: () => <span data-testid="play-icon" />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-icon" />,
}))
describe('RunMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowRunningData = undefined
mockIsPreparingDataSource = false
})
describe('Idle state', () => {
it('should render test run text when no data', () => {
render(<RunMode />)
expect(screen.getByText('pipeline.common.testRun')).toBeInTheDocument()
})
it('should render custom text when provided', () => {
render(<RunMode text="Custom Run" />)
expect(screen.getByText('Custom Run')).toBeInTheDocument()
})
it('should render play icon', () => {
render(<RunMode />)
expect(screen.getByTestId('play-icon')).toBeInTheDocument()
})
it('should render keyboard shortcuts', () => {
render(<RunMode />)
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
})
it('should call start run when button clicked', () => {
render(<RunMode />)
fireEvent.click(screen.getByText('pipeline.common.testRun'))
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
})
})
describe('Running state', () => {
beforeEach(() => {
mockWorkflowRunningData = {
task_id: 'task-1',
result: { status: 'running' },
}
})
it('should show processing text', () => {
render(<RunMode />)
expect(screen.getByText('pipeline.common.processing')).toBeInTheDocument()
})
it('should show stop button', () => {
render(<RunMode />)
expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
})
it('should disable run button', () => {
render(<RunMode />)
const button = screen.getByText('pipeline.common.processing').closest('button')
expect(button).toBeDisabled()
})
it('should call handleStopRun with task_id when stop clicked', () => {
render(<RunMode />)
fireEvent.click(screen.getByTestId('stop-icon').closest('button')!)
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
})
})
describe('After run completed', () => {
it('should show reRun text when previous run data exists', () => {
mockWorkflowRunningData = {
task_id: 'task-1',
result: { status: 'succeeded' },
}
render(<RunMode />)
expect(screen.getByText('pipeline.common.reRun')).toBeInTheDocument()
})
})
describe('Preparing data source state', () => {
beforeEach(() => {
mockIsPreparingDataSource = true
})
it('should show preparing text', () => {
render(<RunMode />)
expect(screen.getByText('pipeline.common.preparingDataSource')).toBeInTheDocument()
})
it('should show database icon', () => {
render(<RunMode />)
expect(screen.getByTestId('database-icon')).toBeInTheDocument()
})
it('should show cancel button with close icon', () => {
render(<RunMode />)
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
})
it('should cancel preparing when close clicked', () => {
render(<RunMode />)
fireEvent.click(screen.getByTestId('close-icon').closest('button')!)
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(false)
})
})
})

View File

@@ -0,0 +1,319 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Popup from '../popup'
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
const mockNotify = vi.fn()
const mockPush = vi.fn()
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
const mockSetPublishedAt = vi.fn()
const mockMutateDatasetRes = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockInvalidPublishedPipelineInfo = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z'
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
let mockPipelineId: string | undefined = 'pipeline-123'
let mockIsAllowPublishAsCustom = true
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
useRouter: () => ({ push: mockPush }),
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
<a href={href}>{children}</a>
),
}))
vi.mock('ahooks', () => ({
useBoolean: (initial: boolean) => {
const state = { value: initial }
return [state.value, {
setFalse: vi.fn(),
setTrue: vi.fn(),
}]
},
useKeyPress: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
publishedAt: mockPublishedAt,
draftUpdatedAt: mockDraftUpdatedAt,
pipelineId: mockPipelineId,
}
return selector(state)
},
useWorkflowStore: () => ({
getState: () => ({
setPublishedAt: mockSetPublishedAt,
}),
}),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
<button
onClick={onClick as () => void}
disabled={disabled as boolean}
data-variant={variant as string}
className={className as string}
>
{children as React.ReactNode}
</button>
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
}) =>
isShow
? (
<div data-testid="confirm-modal">
<span>{title}</span>
<button data-testid="publish-confirm" onClick={onConfirm}>OK</button>
<button data-testid="publish-cancel" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <hr />,
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/icons/src/public/common', () => ({
SparklesSoft: () => <span data-testid="sparkles" />,
}))
vi.mock('@/app/components/base/premium-badge', () => ({
default: ({ children }: { children: React.ReactNode }) => <span data-testid="premium-badge">{children}</span>,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
}))
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: () => mockMutateDatasetRes,
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => () => 'https://docs.dify.ai',
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => mockSetShowPricingModal,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: () => mockIsAllowPublishAsCustom,
}))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => '/api/datasets/ds-123',
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (time: string) => `formatted:${time}`,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidPublishedPipelineInfo,
}))
vi.mock('@/service/use-pipeline', () => ({
publishedPipelineInfoQueryKeyPrefix: ['published-pipeline'],
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
usePublishAsCustomizedPipeline: () => ({
mutateAsync: mockPublishAsCustomizedPipeline,
}),
}))
vi.mock('@/service/use-workflow', () => ({
usePublishWorkflow: () => ({
mutateAsync: mockPublishWorkflow,
}),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}))
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
default: ({ onConfirm, onCancel }: { onConfirm: (name: string, icon: unknown, desc: string) => void, onCancel: () => void }) => (
<div data-testid="publish-as-modal">
<button data-testid="publish-as-confirm" onClick={() => onConfirm('My Pipeline', { icon_type: 'emoji' }, 'desc')}>
Confirm
</button>
<button data-testid="publish-as-cancel" onClick={onCancel}>Cancel</button>
</div>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowRightUpLine: () => <span />,
RiHammerLine: () => <span />,
RiPlayCircleLine: () => <span />,
RiTerminalBoxLine: () => <span />,
}))
describe('Popup', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPublishedAt = '2024-01-01T00:00:00Z'
mockDraftUpdatedAt = '2024-06-01T00:00:00Z'
mockPipelineId = 'pipeline-123'
mockIsAllowPublishAsCustom = true
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Rendering', () => {
it('should render when published', () => {
render(<Popup />)
expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument()
expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument()
})
it('should render unpublished state', () => {
mockPublishedAt = undefined
render(<Popup />)
expect(screen.getByText('workflow.common.currentDraftUnpublished')).toBeInTheDocument()
expect(screen.getByText(/workflow\.common\.autoSaved/)).toBeInTheDocument()
})
it('should render publish button with shortcuts', () => {
render(<Popup />)
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
})
it('should render "Go to Add Documents" button', () => {
render(<Popup />)
expect(screen.getByText('pipeline.common.goToAddDocuments')).toBeInTheDocument()
})
it('should render "API Reference" button', () => {
render(<Popup />)
expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument()
})
it('should render "Publish As" button', () => {
render(<Popup />)
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
})
})
describe('Premium Badge', () => {
it('should not show premium badge when allowed', () => {
mockIsAllowPublishAsCustom = true
render(<Popup />)
expect(screen.queryByTestId('premium-badge')).not.toBeInTheDocument()
})
it('should show premium badge when not allowed', () => {
mockIsAllowPublishAsCustom = false
render(<Popup />)
expect(screen.getByTestId('premium-badge')).toBeInTheDocument()
})
})
describe('Navigation', () => {
it('should navigate to add documents page', () => {
render(<Popup />)
fireEvent.click(screen.getByText('pipeline.common.goToAddDocuments'))
expect(mockPush).toHaveBeenCalledWith('/datasets/ds-123/documents/create-from-pipeline')
})
})
describe('Button disable states', () => {
it('should disable add documents button when not published', () => {
mockPublishedAt = undefined
render(<Popup />)
const btn = screen.getByText('pipeline.common.goToAddDocuments').closest('button')
expect(btn).toBeDisabled()
})
it('should disable publish-as button when not published', () => {
mockPublishedAt = undefined
render(<Popup />)
const btn = screen.getByText('pipeline.common.publishAs').closest('button')
expect(btn).toBeDisabled()
})
})
describe('Publish As Knowledge Pipeline', () => {
it('should show pricing modal when not allowed', () => {
mockIsAllowPublishAsCustom = false
render(<Popup />)
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
})
describe('Time formatting', () => {
it('should format published time', () => {
render(<Popup />)
expect(screen.getByText(/formatted:2024-01-01/)).toBeInTheDocument()
})
it('should format draft updated time when unpublished', () => {
mockPublishedAt = undefined
render(<Popup />)
expect(screen.getByText(/formatted:2024-06-01/)).toBeInTheDocument()
})
})
})

View File

@@ -1,40 +1,17 @@
'use client'
import type { MouseEventHandler } from 'react'
import {
RiAlertFill,
RiCloseLine,
RiFileDownloadLine,
} from '@remixicon/react'
import {
memo,
useCallback,
useRef,
useState,
} from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import {
useImportPipelineDSL,
useImportPipelineDSLConfirm,
} from '@/service/use-pipeline'
import { fetchWorkflowDraft } from '@/service/workflow'
import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal'
import VersionMismatchModal from './version-mismatch-modal'
type UpdateDSLModalProps = {
onCancel: () => void
@@ -48,146 +25,17 @@ const UpdateDSLModal = ({
onImport,
}: UpdateDSLModalProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentFile, setDSLFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [loading, setLoading] = useState(false)
const { eventEmitter } = useEventEmitterContextContext()
const [show, setShow] = useState(true)
const [showErrorModal, setShowErrorModal] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const { handleCheckPluginDependencies } = usePluginDependencies()
const { mutateAsync: importDSL } = useImportPipelineDSL()
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
const workflowStore = useWorkflowStore()
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = function (event) {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setDSLFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
const handleWorkflowUpdate = useCallback(async (pipelineId: string) => {
const {
graph,
hash,
rag_pipeline_variables,
} = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`)
const { nodes, edges, viewport } = graph
eventEmitter?.emit({
type: WORKFLOW_DATA_UPDATE,
payload: {
nodes: initialNodes(nodes, edges),
edges: initialEdges(edges, nodes),
viewport,
hash,
rag_pipeline_variables: rag_pipeline_variables || [],
},
} as any)
}, [eventEmitter])
const isCreatingRef = useRef(false)
const handleImport: MouseEventHandler = useCallback(async () => {
const { pipelineId } = workflowStore.getState()
if (isCreatingRef.current)
return
isCreatingRef.current = true
if (!currentFile)
return
try {
if (pipelineId && fileContent) {
setLoading(true)
const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, pipeline_id: pipelineId })
const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
if (!pipeline_id) {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
return
}
handleWorkflowUpdate(pipeline_id)
if (onImport)
onImport()
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }),
})
await handleCheckPluginDependencies(pipeline_id, true)
setLoading(false)
onCancel()
}
else if (status === DSLImportStatus.PENDING) {
setShow(false)
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
setImportId(id)
}
else {
setLoading(false)
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setLoading(false)
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
isCreatingRef.current = false
}, [currentFile, fileContent, onCancel, notify, t, onImport, handleWorkflowUpdate, handleCheckPluginDependencies, workflowStore, importDSL])
const onUpdateDSLConfirm: MouseEventHandler = async () => {
try {
if (!importId)
return
const response = await importDSLConfirm(importId)
const { status, pipeline_id } = response
if (status === DSLImportStatus.COMPLETED) {
if (!pipeline_id) {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
return
}
handleWorkflowUpdate(pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
if (onImport)
onImport()
notify({ type: 'success', message: t('common.importSuccess', { ns: 'workflow' }) })
setLoading(false)
onCancel()
}
else if (status === DSLImportStatus.FAILED) {
setLoading(false)
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setLoading(false)
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
}
const {
currentFile,
handleFile,
show,
showErrorModal,
setShowErrorModal,
loading,
versions,
handleImport,
onUpdateDSLConfirm,
} = useUpdateDSLModal({ onCancel, onImport })
return (
<>
@@ -250,32 +98,12 @@ const UpdateDSLModal = ({
</Button>
</div>
</Modal>
<Modal
<VersionMismatchModal
isShow={showErrorModal}
versions={versions}
onClose={() => setShowErrorModal(false)}
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="system-md-regular flex grow flex-col text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button variant="primary" destructive onClick={onUpdateDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
</div>
</Modal>
onConfirm={onUpdateDSLConfirm}
/>
</>
)
}

View File

@@ -0,0 +1,54 @@
import type { MouseEventHandler } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
type VersionMismatchModalProps = {
isShow: boolean
versions?: {
importedVersion: string
systemVersion: string
}
onClose: () => void
onConfirm: MouseEventHandler
}
const VersionMismatchModal = ({
isShow,
versions,
onClose,
onConfirm,
}: VersionMismatchModalProps) => {
const { t } = useTranslation()
return (
<Modal
isShow={isShow}
onClose={onClose}
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="system-md-regular flex grow flex-col text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button variant="primary" destructive onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
</div>
</Modal>
)
}
export default VersionMismatchModal

View File

@@ -6,10 +6,6 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { Resolution, TransferMethod } from '@/types/app'
import { FlowType } from '@/types/common'
// ============================================================================
// Import hooks after mocks
// ============================================================================
import {
useAvailableNodesMetaData,
useDSL,
@@ -20,16 +16,11 @@ import {
usePipelineRefreshDraft,
usePipelineRun,
usePipelineStartRun,
} from './index'
import { useConfigsMap } from './use-configs-map'
import { useConfigurations, useInitialData } from './use-input-fields'
import { usePipelineTemplate } from './use-pipeline-template'
} from '../index'
import { useConfigsMap } from '../use-configs-map'
import { useConfigurations, useInitialData } from '../use-input-fields'
import { usePipelineTemplate } from '../use-pipeline-template'
// ============================================================================
// Mocks
// ============================================================================
// Mock the workflow store
const _mockGetState = vi.fn()
const mockUseStore = vi.fn()
const mockUseWorkflowStore = vi.fn()
@@ -39,14 +30,6 @@ vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockUseWorkflowStore(),
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
@@ -54,7 +37,6 @@ vi.mock('@/app/components/base/toast', () => ({
}),
}))
// Mock event emitter context
const mockEventEmit = vi.fn()
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
@@ -64,19 +46,16 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
// Mock i18n docLink
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
START_INITIAL_POSITION: { x: 100, y: 100 },
}))
// Mock workflow constants/node
vi.mock('@/app/components/workflow/constants/node', () => ({
WORKFLOW_COMMON_NODES: [
{
@@ -90,7 +69,6 @@ vi.mock('@/app/components/workflow/constants/node', () => ({
],
}))
// Mock data source defaults
vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({
default: {
metaData: { type: BlockEnum.DataSourceEmpty },
@@ -112,7 +90,6 @@ vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({
},
}))
// Mock workflow utils with all needed exports
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
@@ -123,7 +100,6 @@ vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
}
})
// Mock pipeline service
const mockExportPipelineConfig = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
@@ -131,7 +107,6 @@ vi.mock('@/service/use-pipeline', () => ({
}),
}))
// Mock workflow service
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
@@ -139,10 +114,6 @@ vi.mock('@/service/workflow', () => ({
}),
}))
// ============================================================================
// Tests
// ============================================================================
describe('useConfigsMap', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -307,11 +278,10 @@ describe('useInputFieldPanel', () => {
it('should set edit panel props when toggleInputFieldEditPanel is called', () => {
const { result } = renderHook(() => useInputFieldPanel())
const editContent = { type: 'edit', data: {} }
const editContent = { onClose: vi.fn(), onSubmit: vi.fn() }
act(() => {
// eslint-disable-next-line ts/no-explicit-any
result.current.toggleInputFieldEditPanel(editContent as any)
result.current.toggleInputFieldEditPanel(editContent)
})
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent)

View File

@@ -1,8 +1,7 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useDSL } from './use-DSL'
import { useDSL } from '../use-DSL'
// Mock dependencies
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
@@ -14,7 +13,7 @@ vi.mock('@/context/event-emitter', () => ({
}))
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('./use-nodes-sync-draft', () => ({
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }),
}))
@@ -37,21 +36,10 @@ const mockDownloadBlob = vi.fn()
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
// ============================================================================
// Tests
// ============================================================================
describe('useDSL', () => {
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> }
let originalCreateElement: typeof document.createElement
@@ -62,7 +50,6 @@ describe('useDSL', () => {
beforeEach(() => {
vi.clearAllMocks()
// Create a proper mock link element with all required properties for downloadBlob
mockLink = {
href: '',
download: '',
@@ -71,7 +58,6 @@ describe('useDSL', () => {
remove: vi.fn(),
}
// Save original and mock selectively - only intercept 'a' elements
originalCreateElement = document.createElement.bind(document)
document.createElement = vi.fn((tagName: string) => {
if (tagName === 'a') {
@@ -80,15 +66,12 @@ describe('useDSL', () => {
return originalCreateElement(tagName)
}) as typeof document.createElement
// Mock document.body.appendChild for downloadBlob
originalAppendChild = document.body.appendChild.bind(document.body)
document.body.appendChild = vi.fn(<T extends Node>(node: T): T => node) as typeof document.body.appendChild
// downloadBlob uses window.URL, not URL
mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url')
mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
// Default store state
mockGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
knowledgeName: 'Test Knowledge Base',
@@ -170,7 +153,7 @@ describe('useDSL', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
message: 'app.exportFailed',
})
})
})
@@ -251,7 +234,7 @@ describe('useDSL', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
message: 'app.exportFailed',
})
})
})

View File

@@ -0,0 +1,130 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai${path || ''}`,
}))
vi.mock('@/app/components/workflow/constants/node', () => ({
WORKFLOW_COMMON_NODES: [
{
metaData: { type: BlockEnum.LLM },
defaultValue: { title: 'LLM' },
},
{
metaData: { type: BlockEnum.HumanInput },
defaultValue: { title: 'Human Input' },
},
{
metaData: { type: BlockEnum.HttpRequest },
defaultValue: { title: 'HTTP Request' },
},
],
}))
vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({
default: {
metaData: { type: BlockEnum.DataSourceEmpty },
defaultValue: { title: 'Data Source Empty' },
},
}))
vi.mock('@/app/components/workflow/nodes/data-source/default', () => ({
default: {
metaData: { type: BlockEnum.DataSource },
defaultValue: { title: 'Data Source' },
},
}))
vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({
default: {
metaData: { type: BlockEnum.KnowledgeBase },
defaultValue: { title: 'Knowledge Base' },
},
}))
describe('useAvailableNodesMetaData', () => {
it('should return nodes and nodesMap', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodes).toBeDefined()
expect(result.current.nodesMap).toBeDefined()
})
it('should filter out HumanInput node', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(n => n.metaData.type)
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
})
it('should include DataSource with _dataSourceStartToAdd flag', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const dsNode = result.current.nodes.find(n => n.metaData.type === BlockEnum.DataSource)
expect(dsNode).toBeDefined()
expect(dsNode!.defaultValue._dataSourceStartToAdd).toBe(true)
})
it('should include KnowledgeBase and DataSourceEmpty nodes', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(n => n.metaData.type)
expect(nodeTypes).toContain(BlockEnum.KnowledgeBase)
expect(nodeTypes).toContain(BlockEnum.DataSourceEmpty)
})
it('should translate title and description for each node', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
result.current.nodes.forEach((node) => {
expect(node.metaData.title).toMatch(/^workflow\.blocks\./)
expect(node.metaData.description).toMatch(/^workflow\.blocksAbout\./)
})
})
it('should set helpLinkUri on each node metaData', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
result.current.nodes.forEach((node) => {
expect(node.metaData.helpLinkUri).toContain('https://docs.dify.ai')
expect(node.metaData.helpLinkUri).toContain('knowledge-pipeline')
})
})
it('should set type and title on defaultValue', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
result.current.nodes.forEach((node) => {
expect(node.defaultValue.type).toBe(node.metaData.type)
expect(node.defaultValue.title).toBe(node.metaData.title)
})
})
it('should build nodesMap indexed by BlockEnum type', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const { nodesMap } = result.current
expect(nodesMap[BlockEnum.LLM]).toBeDefined()
expect(nodesMap[BlockEnum.DataSource]).toBeDefined()
expect(nodesMap[BlockEnum.KnowledgeBase]).toBeDefined()
})
it('should alias VariableAssigner to VariableAggregator in nodesMap', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const { nodesMap } = result.current
expect(nodesMap[BlockEnum.VariableAssigner]).toBe(nodesMap[BlockEnum.VariableAggregator])
})
it('should include common nodes except HumanInput', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
const nodeTypes = result.current.nodes.map(n => n.metaData.type)
expect(nodeTypes).toContain(BlockEnum.LLM)
expect(nodeTypes).toContain(BlockEnum.HttpRequest)
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
})
})

View File

@@ -0,0 +1,70 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useConfigsMap } from '../use-configs-map'
const mockPipelineId = 'pipeline-xyz'
const mockFileUploadConfig = { max_size: 10 }
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
pipelineId: mockPipelineId,
fileUploadConfig: mockFileUploadConfig,
}
return selector(state)
},
}))
vi.mock('@/types/app', () => ({
Resolution: { high: 'high' },
TransferMethod: { local_file: 'local_file', remote_url: 'remote_url' },
}))
vi.mock('@/types/common', () => ({
FlowType: { ragPipeline: 'rag-pipeline' },
}))
describe('useConfigsMap', () => {
it('should return flowId from pipelineId', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.flowId).toBe('pipeline-xyz')
})
it('should return ragPipeline as flowType', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.flowType).toBe('rag-pipeline')
})
it('should include file settings with image disabled', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.image.enabled).toBe(false)
})
it('should set image detail to high resolution', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.image.detail).toBe('high')
})
it('should set image number_limits to 3', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.image.number_limits).toBe(3)
})
it('should include both transfer methods for image', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.image.transfer_methods).toEqual(['local_file', 'remote_url'])
})
it('should pass through fileUploadConfig from store', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.fileUploadConfig).toEqual({ max_size: 10 })
})
})

View File

@@ -0,0 +1,45 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
pipelineId: 'pipeline-test-123',
}),
}),
}))
describe('useGetRunAndTraceUrl', () => {
it('should return a function getWorkflowRunAndTraceUrl', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
expect(typeof result.current.getWorkflowRunAndTraceUrl).toBe('function')
})
it('should generate correct runUrl', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
const { runUrl } = result.current.getWorkflowRunAndTraceUrl('run-abc')
expect(runUrl).toBe('/rag/pipelines/pipeline-test-123/workflow-runs/run-abc')
})
it('should generate correct traceUrl', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
const { traceUrl } = result.current.getWorkflowRunAndTraceUrl('run-abc')
expect(traceUrl).toBe('/rag/pipelines/pipeline-test-123/workflow-runs/run-abc/node-executions')
})
it('should handle different runIds', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
const r1 = result.current.getWorkflowRunAndTraceUrl('id-1')
const r2 = result.current.getWorkflowRunAndTraceUrl('id-2')
expect(r1.runUrl).toContain('id-1')
expect(r2.runUrl).toContain('id-2')
expect(r1.runUrl).not.toBe(r2.runUrl)
})
})

View File

@@ -0,0 +1,130 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useInputFieldPanel } from '../use-input-field-panel'
const mockSetShowInputFieldPanel = vi.fn()
const mockSetShowInputFieldPreviewPanel = vi.fn()
const mockSetInputFieldEditPanelProps = vi.fn()
let mockShowInputFieldPreviewPanel = false
let mockInputFieldEditPanelProps: unknown = null
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
showInputFieldPreviewPanel: mockShowInputFieldPreviewPanel,
setShowInputFieldPanel: mockSetShowInputFieldPanel,
setShowInputFieldPreviewPanel: mockSetShowInputFieldPreviewPanel,
setInputFieldEditPanelProps: mockSetInputFieldEditPanelProps,
}),
}),
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
showInputFieldPreviewPanel: mockShowInputFieldPreviewPanel,
inputFieldEditPanelProps: mockInputFieldEditPanelProps,
}
return selector(state)
},
}))
describe('useInputFieldPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockShowInputFieldPreviewPanel = false
mockInputFieldEditPanelProps = null
})
describe('isPreviewing', () => {
it('should return false when preview panel is hidden', () => {
mockShowInputFieldPreviewPanel = false
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isPreviewing).toBe(false)
})
it('should return true when preview panel is shown', () => {
mockShowInputFieldPreviewPanel = true
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isPreviewing).toBe(true)
})
})
describe('isEditing', () => {
it('should return false when no edit panel props', () => {
mockInputFieldEditPanelProps = null
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isEditing).toBe(false)
})
it('should return true when edit panel props exist', () => {
mockInputFieldEditPanelProps = { onSubmit: vi.fn(), onClose: vi.fn() }
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isEditing).toBe(true)
})
})
describe('closeAllInputFieldPanels', () => {
it('should close all panels and clear edit props', () => {
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.closeAllInputFieldPanels()
})
expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(false)
expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false)
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null)
})
})
describe('toggleInputFieldPreviewPanel', () => {
it('should toggle preview panel from false to true', () => {
mockShowInputFieldPreviewPanel = false
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.toggleInputFieldPreviewPanel()
})
expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(true)
})
it('should toggle preview panel from true to false', () => {
mockShowInputFieldPreviewPanel = true
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.toggleInputFieldPreviewPanel()
})
expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false)
})
})
describe('toggleInputFieldEditPanel', () => {
it('should set edit panel props when given content', () => {
const editContent = { onSubmit: vi.fn(), onClose: vi.fn() }
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.toggleInputFieldEditPanel(editContent)
})
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent)
})
it('should clear edit panel props when given null', () => {
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.toggleInputFieldEditPanel(null)
})
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null)
})
})
})

Some files were not shown because too many files have changed in this diff Show More