Merge branch 'main' into feat-agent-mask

This commit is contained in:
GuanMu 2025-12-16 13:43:11 +08:00 committed by GitHub
commit 97a5c3f3f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 6797 additions and 238 deletions

View File

@ -76,7 +76,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
- [ ] `jest.clearAllMocks()` in `beforeEach` (not `afterEach`)
- [ ] Shared mock state reset in `beforeEach`
- [ ] i18n mock returns keys (not empty strings)
- [ ] i18n uses shared mock (auto-loaded); only override locally for custom translations
- [ ] Router mocks match actual Next.js API
- [ ] Mocks reflect actual component conditional behavior
- [ ] Only mock: API services, complex context providers, third-party libs

View File

@ -318,3 +318,4 @@ For more detailed information, refer to:
- `web/jest.config.ts` - Jest configuration
- `web/jest.setup.ts` - Test environment setup
- `web/testing/analyze-component.js` - Component analysis tool
- `web/__mocks__/react-i18next.ts` - Shared i18n mock (auto-loaded by Jest, no explicit mock needed; override locally only for custom translations)

View File

@ -46,12 +46,22 @@ Only mock these categories:
## Essential Mocks
### 1. i18n (Always Required)
### 1. i18n (Auto-loaded via Shared Mock)
A shared mock is available at `web/__mocks__/react-i18next.ts` and is auto-loaded by Jest.
**No explicit mock needed** for most tests - it returns translation keys as-is.
For tests requiring custom translations, override the mock:
```typescript
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string) => {
const translations: Record<string, string> = {
'my.custom.key': 'Custom translation',
}
return translations[key] || key
},
}),
}))
```
@ -313,7 +323,7 @@ Need to use a component in test?
│ └─ YES → Mock it (next/navigation, external SDKs)
└─ Is it i18n?
└─ YES → Mock to return keys
└─ YES → Uses shared mock (auto-loaded). Override only for custom translations
```
## Factory Function Pattern

View File

@ -107,7 +107,7 @@ class KeywordStoreConfig(BaseSettings):
class DatabaseConfig(BaseSettings):
# Database type selector
DB_TYPE: Literal["postgresql", "mysql", "oceanbase"] = Field(
DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field(
description="Database type to use. OceanBase is MySQL-compatible.",
default="postgresql",
)

View File

@ -223,6 +223,7 @@ def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool
VectorType.COUCHBASE,
VectorType.OPENGAUSS,
VectorType.OCEANBASE,
VectorType.SEEKDB,
VectorType.TABLESTORE,
VectorType.HUAWEI_CLOUD,
VectorType.TENCENT,

View File

@ -163,7 +163,7 @@ class Vector:
from core.rag.datasource.vdb.lindorm.lindorm_vector import LindormVectorStoreFactory
return LindormVectorStoreFactory
case VectorType.OCEANBASE:
case VectorType.OCEANBASE | VectorType.SEEKDB:
from core.rag.datasource.vdb.oceanbase.oceanbase_vector import OceanBaseVectorFactory
return OceanBaseVectorFactory

View File

@ -27,6 +27,7 @@ class VectorType(StrEnum):
UPSTASH = "upstash"
TIDB_ON_QDRANT = "tidb_on_qdrant"
OCEANBASE = "oceanbase"
SEEKDB = "seekdb"
OPENGAUSS = "opengauss"
TABLESTORE = "tablestore"
HUAWEI_CLOUD = "huawei_cloud"

View File

@ -140,6 +140,10 @@ class GraphEngine:
pause_handler = PauseCommandHandler()
self._command_processor.register_handler(PauseCommand, pause_handler)
# === Extensibility ===
# Layers allow plugins to extend engine functionality
self._layers: list[GraphEngineLayer] = []
# === Worker Pool Setup ===
# Capture Flask app context for worker threads
flask_app: Flask | None = None
@ -158,6 +162,7 @@ class GraphEngine:
ready_queue=self._ready_queue,
event_queue=self._event_queue,
graph=self._graph,
layers=self._layers,
flask_app=flask_app,
context_vars=context_vars,
min_workers=self._min_workers,
@ -196,10 +201,6 @@ class GraphEngine:
event_emitter=self._event_manager,
)
# === Extensibility ===
# Layers allow plugins to extend engine functionality
self._layers: list[GraphEngineLayer] = []
# === Validation ===
# Ensure all nodes share the same GraphRuntimeState instance
self._validate_graph_state_consistency()

View File

@ -8,9 +8,11 @@ with middleware-like components that can observe events and interact with execut
from .base import GraphEngineLayer
from .debug_logging import DebugLoggingLayer
from .execution_limits import ExecutionLimitsLayer
from .observability import ObservabilityLayer
__all__ = [
"DebugLoggingLayer",
"ExecutionLimitsLayer",
"GraphEngineLayer",
"ObservabilityLayer",
]

View File

@ -9,6 +9,7 @@ from abc import ABC, abstractmethod
from core.workflow.graph_engine.protocols.command_channel import CommandChannel
from core.workflow.graph_events import GraphEngineEvent
from core.workflow.nodes.base.node import Node
from core.workflow.runtime import ReadOnlyGraphRuntimeState
@ -83,3 +84,29 @@ class GraphEngineLayer(ABC):
error: The exception that caused execution to fail, or None if successful
"""
pass
def on_node_run_start(self, node: Node) -> None: # noqa: B027
"""
Called immediately before a node begins execution.
Layers can override to inject behavior (e.g., start spans) prior to node execution.
The node's execution ID is available via `node._node_execution_id` and will be
consistent with all events emitted by this node execution.
Args:
node: The node instance about to be executed
"""
pass
def on_node_run_end(self, node: Node, error: Exception | None) -> None: # noqa: B027
"""
Called after a node finishes execution.
The node's execution ID is available via `node._node_execution_id` and matches
the `id` field in all events emitted by this node execution.
Args:
node: The node instance that just finished execution
error: Exception instance if the node failed, otherwise None
"""
pass

View File

@ -0,0 +1,61 @@
"""
Node-level OpenTelemetry parser interfaces and defaults.
"""
import json
from typing import Protocol
from opentelemetry.trace import Span
from opentelemetry.trace.status import Status, StatusCode
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.tool.entities import ToolNodeData
class NodeOTelParser(Protocol):
"""Parser interface for node-specific OpenTelemetry enrichment."""
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None: ...
class DefaultNodeOTelParser:
"""Fallback parser used when no node-specific parser is registered."""
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None:
span.set_attribute("node.id", node.id)
if node.execution_id:
span.set_attribute("node.execution_id", node.execution_id)
if hasattr(node, "node_type") and node.node_type:
span.set_attribute("node.type", node.node_type.value)
if error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR, str(error)))
else:
span.set_status(Status(StatusCode.OK))
class ToolNodeOTelParser:
"""Parser for tool nodes that captures tool-specific metadata."""
def __init__(self) -> None:
self._delegate = DefaultNodeOTelParser()
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None:
self._delegate.parse(node=node, span=span, error=error)
tool_data = getattr(node, "_node_data", None)
if not isinstance(tool_data, ToolNodeData):
return
span.set_attribute("tool.provider.id", tool_data.provider_id)
span.set_attribute("tool.provider.type", tool_data.provider_type.value)
span.set_attribute("tool.provider.name", tool_data.provider_name)
span.set_attribute("tool.name", tool_data.tool_name)
span.set_attribute("tool.label", tool_data.tool_label)
if tool_data.plugin_unique_identifier:
span.set_attribute("tool.plugin.id", tool_data.plugin_unique_identifier)
if tool_data.credential_id:
span.set_attribute("tool.credential.id", tool_data.credential_id)
if tool_data.tool_configurations:
span.set_attribute("tool.config", json.dumps(tool_data.tool_configurations, ensure_ascii=False))

View File

@ -0,0 +1,169 @@
"""
Observability layer for GraphEngine.
This layer creates OpenTelemetry spans for node execution, enabling distributed
tracing of workflow execution. It establishes OTel context during node execution
so that automatic instrumentation (HTTP requests, DB queries, etc.) automatically
associates with the node span.
"""
import logging
from dataclasses import dataclass
from typing import cast, final
from opentelemetry import context as context_api
from opentelemetry.trace import Span, SpanKind, Tracer, get_tracer, set_span_in_context
from typing_extensions import override
from configs import dify_config
from core.workflow.enums import NodeType
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_engine.layers.node_parsers import (
DefaultNodeOTelParser,
NodeOTelParser,
ToolNodeOTelParser,
)
from core.workflow.nodes.base.node import Node
from extensions.otel.runtime import is_instrument_flag_enabled
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _NodeSpanContext:
span: "Span"
token: object
@final
class ObservabilityLayer(GraphEngineLayer):
"""
Layer that creates OpenTelemetry spans for node execution.
This layer:
- Creates a span when a node starts execution
- Establishes OTel context so automatic instrumentation associates with the span
- Sets complete attributes and status when node execution ends
"""
def __init__(self) -> None:
super().__init__()
self._node_contexts: dict[str, _NodeSpanContext] = {}
self._parsers: dict[NodeType, NodeOTelParser] = {}
self._default_parser: NodeOTelParser = cast(NodeOTelParser, DefaultNodeOTelParser())
self._is_disabled: bool = False
self._tracer: Tracer | None = None
self._build_parser_registry()
self._init_tracer()
def _init_tracer(self) -> None:
"""Initialize OpenTelemetry tracer in constructor."""
if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()):
self._is_disabled = True
return
try:
self._tracer = get_tracer(__name__)
except Exception as e:
logger.warning("Failed to get OpenTelemetry tracer: %s", e)
self._is_disabled = True
def _build_parser_registry(self) -> None:
"""Initialize parser registry for node types."""
self._parsers = {
NodeType.TOOL: ToolNodeOTelParser(),
}
def _get_parser(self, node: Node) -> NodeOTelParser:
node_type = getattr(node, "node_type", None)
if isinstance(node_type, NodeType):
return self._parsers.get(node_type, self._default_parser)
return self._default_parser
@override
def on_graph_start(self) -> None:
"""Called when graph execution starts."""
self._node_contexts.clear()
@override
def on_node_run_start(self, node: Node) -> None:
"""
Called when a node starts execution.
Creates a span and establishes OTel context for automatic instrumentation.
"""
if self._is_disabled:
return
try:
if not self._tracer:
return
execution_id = node.execution_id
if not execution_id:
return
parent_context = context_api.get_current()
span = self._tracer.start_span(
f"{node.title}",
kind=SpanKind.INTERNAL,
context=parent_context,
)
new_context = set_span_in_context(span)
token = context_api.attach(new_context)
self._node_contexts[execution_id] = _NodeSpanContext(span=span, token=token)
except Exception as e:
logger.warning("Failed to create OpenTelemetry span for node %s: %s", node.id, e)
@override
def on_node_run_end(self, node: Node, error: Exception | None) -> None:
"""
Called when a node finishes execution.
Sets complete attributes, records exceptions, and ends the span.
"""
if self._is_disabled:
return
try:
execution_id = node.execution_id
if not execution_id:
return
node_context = self._node_contexts.get(execution_id)
if not node_context:
return
span = node_context.span
parser = self._get_parser(node)
try:
parser.parse(node=node, span=span, error=error)
span.end()
finally:
token = node_context.token
if token is not None:
try:
context_api.detach(token)
except Exception:
logger.warning("Failed to detach OpenTelemetry token: %s", token)
self._node_contexts.pop(execution_id, None)
except Exception as e:
logger.warning("Failed to end OpenTelemetry span for node %s: %s", node.id, e)
@override
def on_event(self, event) -> None:
"""Not used in this layer."""
pass
@override
def on_graph_end(self, error: Exception | None) -> None:
"""Called when graph execution ends."""
if self._node_contexts:
logger.warning(
"ObservabilityLayer: %d node spans were not properly ended",
len(self._node_contexts),
)
self._node_contexts.clear()

View File

@ -9,6 +9,7 @@ import contextvars
import queue
import threading
import time
from collections.abc import Sequence
from datetime import datetime
from typing import final
from uuid import uuid4
@ -17,6 +18,7 @@ from flask import Flask
from typing_extensions import override
from core.workflow.graph import Graph
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent
from core.workflow.nodes.base.node import Node
from libs.flask_utils import preserve_flask_contexts
@ -39,6 +41,7 @@ class Worker(threading.Thread):
ready_queue: ReadyQueue,
event_queue: queue.Queue[GraphNodeEventBase],
graph: Graph,
layers: Sequence[GraphEngineLayer],
worker_id: int = 0,
flask_app: Flask | None = None,
context_vars: contextvars.Context | None = None,
@ -50,6 +53,7 @@ class Worker(threading.Thread):
ready_queue: Ready queue containing node IDs ready for execution
event_queue: Queue for pushing execution events
graph: Graph containing nodes to execute
layers: Graph engine layers for node execution hooks
worker_id: Unique identifier for this worker
flask_app: Optional Flask application for context preservation
context_vars: Optional context variables to preserve in worker thread
@ -63,6 +67,7 @@ class Worker(threading.Thread):
self._context_vars = context_vars
self._stop_event = threading.Event()
self._last_task_time = time.time()
self._layers = layers if layers is not None else []
def stop(self) -> None:
"""Signal the worker to stop processing."""
@ -122,20 +127,51 @@ class Worker(threading.Thread):
Args:
node: The node instance to execute
"""
# Execute the node with preserved context if Flask app is provided
node.ensure_execution_id()
error: Exception | None = None
if self._flask_app and self._context_vars:
with preserve_flask_contexts(
flask_app=self._flask_app,
context_vars=self._context_vars,
):
# Execute the node
self._invoke_node_run_start_hooks(node)
try:
node_events = node.run()
for event in node_events:
self._event_queue.put(event)
except Exception as exc:
error = exc
raise
finally:
self._invoke_node_run_end_hooks(node, error)
else:
self._invoke_node_run_start_hooks(node)
try:
node_events = node.run()
for event in node_events:
# Forward event to dispatcher immediately for streaming
self._event_queue.put(event)
else:
# Execute without context preservation
node_events = node.run()
for event in node_events:
# Forward event to dispatcher immediately for streaming
self._event_queue.put(event)
except Exception as exc:
error = exc
raise
finally:
self._invoke_node_run_end_hooks(node, error)
def _invoke_node_run_start_hooks(self, node: Node) -> None:
"""Invoke on_node_run_start hooks for all layers."""
for layer in self._layers:
try:
layer.on_node_run_start(node)
except Exception:
# Silently ignore layer errors to prevent disrupting node execution
continue
def _invoke_node_run_end_hooks(self, node: Node, error: Exception | None) -> None:
"""Invoke on_node_run_end hooks for all layers."""
for layer in self._layers:
try:
layer.on_node_run_end(node, error)
except Exception:
# Silently ignore layer errors to prevent disrupting node execution
continue

View File

@ -14,6 +14,7 @@ from configs import dify_config
from core.workflow.graph import Graph
from core.workflow.graph_events import GraphNodeEventBase
from ..layers.base import GraphEngineLayer
from ..ready_queue import ReadyQueue
from ..worker import Worker
@ -39,6 +40,7 @@ class WorkerPool:
ready_queue: ReadyQueue,
event_queue: queue.Queue[GraphNodeEventBase],
graph: Graph,
layers: list[GraphEngineLayer],
flask_app: "Flask | None" = None,
context_vars: "Context | None" = None,
min_workers: int | None = None,
@ -53,6 +55,7 @@ class WorkerPool:
ready_queue: Ready queue for nodes ready for execution
event_queue: Queue for worker events
graph: The workflow graph
layers: Graph engine layers for node execution hooks
flask_app: Optional Flask app for context preservation
context_vars: Optional context variables
min_workers: Minimum number of workers
@ -65,6 +68,7 @@ class WorkerPool:
self._graph = graph
self._flask_app = flask_app
self._context_vars = context_vars
self._layers = layers
# Scaling parameters with defaults
self._min_workers = min_workers or dify_config.GRAPH_ENGINE_MIN_WORKERS
@ -144,6 +148,7 @@ class WorkerPool:
ready_queue=self._ready_queue,
event_queue=self._event_queue,
graph=self._graph,
layers=self._layers,
worker_id=worker_id,
flask_app=self._flask_app,
context_vars=self._context_vars,

View File

@ -244,6 +244,15 @@ class Node(Generic[NodeDataT]):
def graph_init_params(self) -> "GraphInitParams":
return self._graph_init_params
@property
def execution_id(self) -> str:
return self._node_execution_id
def ensure_execution_id(self) -> str:
if not self._node_execution_id:
self._node_execution_id = str(uuid4())
return self._node_execution_id
def _hydrate_node_data(self, data: Mapping[str, Any]) -> NodeDataT:
return cast(NodeDataT, self._node_data_type.model_validate(data))
@ -256,14 +265,12 @@ class Node(Generic[NodeDataT]):
raise NotImplementedError
def run(self) -> Generator[GraphNodeEventBase, None, None]:
# Generate a single node execution ID to use for all events
if not self._node_execution_id:
self._node_execution_id = str(uuid4())
execution_id = self.ensure_execution_id()
self._start_at = naive_utc_now()
# Create and push start event with required fields
start_event = NodeRunStartedEvent(
id=self._node_execution_id,
id=execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.title,
@ -321,7 +328,7 @@ class Node(Generic[NodeDataT]):
if isinstance(event, NodeEventBase): # pyright: ignore[reportUnnecessaryIsInstance]
yield self._dispatch(event)
elif isinstance(event, GraphNodeEventBase) and not event.in_iteration_id and not event.in_loop_id: # pyright: ignore[reportUnnecessaryIsInstance]
event.id = self._node_execution_id
event.id = self.execution_id
yield event
else:
yield event
@ -333,7 +340,7 @@ class Node(Generic[NodeDataT]):
error_type="WorkflowNodeError",
)
yield NodeRunFailedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
start_at=self._start_at,
@ -512,7 +519,7 @@ class Node(Generic[NodeDataT]):
match result.status:
case WorkflowNodeExecutionStatus.FAILED:
return NodeRunFailedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self.id,
node_type=self.node_type,
start_at=self._start_at,
@ -521,7 +528,7 @@ class Node(Generic[NodeDataT]):
)
case WorkflowNodeExecutionStatus.SUCCEEDED:
return NodeRunSucceededEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self.id,
node_type=self.node_type,
start_at=self._start_at,
@ -537,7 +544,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: StreamChunkEvent) -> NodeRunStreamChunkEvent:
return NodeRunStreamChunkEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
selector=event.selector,
@ -550,7 +557,7 @@ class Node(Generic[NodeDataT]):
match event.node_run_result.status:
case WorkflowNodeExecutionStatus.SUCCEEDED:
return NodeRunSucceededEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
start_at=self._start_at,
@ -558,7 +565,7 @@ class Node(Generic[NodeDataT]):
)
case WorkflowNodeExecutionStatus.FAILED:
return NodeRunFailedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
start_at=self._start_at,
@ -573,7 +580,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: PauseRequestedEvent) -> NodeRunPauseRequestedEvent:
return NodeRunPauseRequestedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.PAUSED),
@ -583,7 +590,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: AgentLogEvent) -> NodeRunAgentLogEvent:
return NodeRunAgentLogEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
message_id=event.message_id,
@ -599,7 +606,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: LoopStartedEvent) -> NodeRunLoopStartedEvent:
return NodeRunLoopStartedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -612,7 +619,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: LoopNextEvent) -> NodeRunLoopNextEvent:
return NodeRunLoopNextEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -623,7 +630,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: LoopSucceededEvent) -> NodeRunLoopSucceededEvent:
return NodeRunLoopSucceededEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -637,7 +644,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: LoopFailedEvent) -> NodeRunLoopFailedEvent:
return NodeRunLoopFailedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -652,7 +659,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: IterationStartedEvent) -> NodeRunIterationStartedEvent:
return NodeRunIterationStartedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -665,7 +672,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: IterationNextEvent) -> NodeRunIterationNextEvent:
return NodeRunIterationNextEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -676,7 +683,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: IterationSucceededEvent) -> NodeRunIterationSucceededEvent:
return NodeRunIterationSucceededEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -690,7 +697,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: IterationFailedEvent) -> NodeRunIterationFailedEvent:
return NodeRunIterationFailedEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
node_title=self.node_data.title,
@ -705,7 +712,7 @@ class Node(Generic[NodeDataT]):
@_dispatch.register
def _(self, event: RunRetrieverResourceEvent) -> NodeRunRetrieverResourceEvent:
return NodeRunRetrieverResourceEvent(
id=self._node_execution_id,
id=self.execution_id,
node_id=self._node_id,
node_type=self.node_type,
retriever_resources=event.retriever_resources,

View File

@ -14,7 +14,7 @@ from core.workflow.errors import WorkflowNodeRunFailedError
from core.workflow.graph import Graph
from core.workflow.graph_engine import GraphEngine
from core.workflow.graph_engine.command_channels import InMemoryChannel
from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer
from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer, ObservabilityLayer
from core.workflow.graph_engine.protocols.command_channel import CommandChannel
from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent
from core.workflow.nodes import NodeType
@ -23,6 +23,7 @@ from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
from extensions.otel.runtime import is_instrument_flag_enabled
from factories import file_factory
from models.enums import UserFrom
from models.workflow import Workflow
@ -98,6 +99,10 @@ class WorkflowEntry:
)
self.graph_engine.layer(limits_layer)
# Add observability layer when OTel is enabled
if dify_config.ENABLE_OTEL or is_instrument_flag_enabled():
self.graph_engine.layer(ObservabilityLayer())
def run(self) -> Generator[GraphEngineEvent, None, None]:
graph_engine = self.graph_engine

View File

@ -1,5 +1,4 @@
import functools
import os
from collections.abc import Callable
from typing import Any, TypeVar, cast
@ -7,22 +6,13 @@ from opentelemetry.trace import get_tracer
from configs import dify_config
from extensions.otel.decorators.handler import SpanHandler
from extensions.otel.runtime import is_instrument_flag_enabled
T = TypeVar("T", bound=Callable[..., Any])
_HANDLER_INSTANCES: dict[type[SpanHandler], SpanHandler] = {SpanHandler: SpanHandler()}
def _is_instrument_flag_enabled() -> bool:
"""
Check if external instrumentation is enabled via environment variable.
Third-party non-invasive instrumentation agents set this flag to coordinate
with Dify's manual OpenTelemetry instrumentation.
"""
return os.getenv("ENABLE_OTEL_FOR_INSTRUMENT", "").strip().lower() == "true"
def _get_handler_instance(handler_class: type[SpanHandler]) -> SpanHandler:
"""Get or create a singleton instance of the handler class."""
if handler_class not in _HANDLER_INSTANCES:
@ -43,7 +33,7 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T],
def decorator(func: T) -> T:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not (dify_config.ENABLE_OTEL or _is_instrument_flag_enabled()):
if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()):
return func(*args, **kwargs)
handler = _get_handler_instance(handler_class or SpanHandler)

View File

@ -1,4 +1,5 @@
import logging
import os
import sys
from typing import Union
@ -71,3 +72,13 @@ def init_celery_worker(*args, **kwargs):
if dify_config.DEBUG:
logger.info("Initializing OpenTelemetry for Celery worker")
CeleryInstrumentor(tracer_provider=tracer_provider, meter_provider=metric_provider).instrument()
def is_instrument_flag_enabled() -> bool:
"""
Check if external instrumentation is enabled via environment variable.
Third-party non-invasive instrumentation agents set this flag to coordinate
with Dify's manual OpenTelemetry instrumentation.
"""
return os.getenv("ENABLE_OTEL_FOR_INSTRUMENT", "").strip().lower() == "true"

View File

@ -184,7 +184,7 @@ def timezone(timezone_string):
def convert_datetime_to_date(field, target_timezone: str = ":tz"):
if dify_config.DB_TYPE == "postgresql":
return f"DATE(DATE_TRUNC('day', {field} AT TIME ZONE 'UTC' AT TIME ZONE {target_timezone}))"
elif dify_config.DB_TYPE == "mysql":
elif dify_config.DB_TYPE in ["mysql", "oceanbase", "seekdb"]:
return f"DATE(CONVERT_TZ({field}, 'UTC', {target_timezone}))"
else:
raise NotImplementedError(f"Unsupported database type: {dify_config.DB_TYPE}")

View File

@ -33,6 +33,11 @@ from services.errors.app import QuotaExceededError
from services.trigger.app_trigger_service import AppTriggerService
from services.workflow.entities import WebhookTriggerData
try:
import magic
except ImportError:
magic = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
@ -317,7 +322,8 @@ class WebhookService:
try:
file_content = request.get_data()
if file_content:
file_obj = cls._create_file_from_binary(file_content, "application/octet-stream", webhook_trigger)
mimetype = cls._detect_binary_mimetype(file_content)
file_obj = cls._create_file_from_binary(file_content, mimetype, webhook_trigger)
return {"raw": file_obj.to_dict()}, {}
else:
return {"raw": None}, {}
@ -341,6 +347,18 @@ class WebhookService:
body = {"raw": ""}
return body, {}
@staticmethod
def _detect_binary_mimetype(file_content: bytes) -> str:
"""Guess MIME type for binary payloads using python-magic when available."""
if magic is not None:
try:
detected = magic.from_buffer(file_content[:1024], mime=True)
if detected:
return detected
except Exception:
logger.debug("python-magic detection failed for octet-stream payload")
return "application/octet-stream"
@classmethod
def _process_file_uploads(
cls, files: Mapping[str, FileStorage], webhook_trigger: WorkflowWebhookTrigger

View File

@ -0,0 +1,101 @@
"""
Shared fixtures for ObservabilityLayer tests.
"""
from unittest.mock import MagicMock, patch
import pytest
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import set_tracer_provider
from core.workflow.enums import NodeType
@pytest.fixture
def memory_span_exporter():
"""Provide an in-memory span exporter for testing."""
return InMemorySpanExporter()
@pytest.fixture
def tracer_provider_with_memory_exporter(memory_span_exporter):
"""Provide a TracerProvider configured with memory exporter."""
import opentelemetry.trace as trace_api
trace_api._TRACER_PROVIDER = None
trace_api._TRACER_PROVIDER_SET_ONCE._done = False
provider = TracerProvider()
processor = SimpleSpanProcessor(memory_span_exporter)
provider.add_span_processor(processor)
set_tracer_provider(provider)
yield provider
provider.force_flush()
@pytest.fixture
def mock_start_node():
"""Create a mock Start Node."""
node = MagicMock()
node.id = "test-start-node-id"
node.title = "Start Node"
node.execution_id = "test-start-execution-id"
node.node_type = NodeType.START
return node
@pytest.fixture
def mock_llm_node():
"""Create a mock LLM Node."""
node = MagicMock()
node.id = "test-llm-node-id"
node.title = "LLM Node"
node.execution_id = "test-llm-execution-id"
node.node_type = NodeType.LLM
return node
@pytest.fixture
def mock_tool_node():
"""Create a mock Tool Node with tool-specific attributes."""
from core.tools.entities.tool_entities import ToolProviderType
from core.workflow.nodes.tool.entities import ToolNodeData
node = MagicMock()
node.id = "test-tool-node-id"
node.title = "Test Tool Node"
node.execution_id = "test-tool-execution-id"
node.node_type = NodeType.TOOL
tool_data = ToolNodeData(
title="Test Tool Node",
desc=None,
provider_id="test-provider-id",
provider_type=ToolProviderType.BUILT_IN,
provider_name="test-provider",
tool_name="test-tool",
tool_label="Test Tool",
tool_configurations={},
tool_parameters={},
)
node._node_data = tool_data
return node
@pytest.fixture
def mock_is_instrument_flag_enabled_false():
"""Mock is_instrument_flag_enabled to return False."""
with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=False):
yield
@pytest.fixture
def mock_is_instrument_flag_enabled_true():
"""Mock is_instrument_flag_enabled to return True."""
with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=True):
yield

View File

@ -0,0 +1,219 @@
"""
Tests for ObservabilityLayer.
Test coverage:
- Initialization and enable/disable logic
- Node span lifecycle (start, end, error handling)
- Parser integration (default and tool-specific)
- Graph lifecycle management
- Disabled mode behavior
"""
from unittest.mock import patch
import pytest
from opentelemetry.trace import StatusCode
from core.workflow.enums import NodeType
from core.workflow.graph_engine.layers.observability import ObservabilityLayer
class TestObservabilityLayerInitialization:
"""Test ObservabilityLayer initialization logic."""
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_initialization_when_otel_enabled(self, tracer_provider_with_memory_exporter):
"""Test that layer initializes correctly when OTel is enabled."""
layer = ObservabilityLayer()
assert not layer._is_disabled
assert layer._tracer is not None
assert NodeType.TOOL in layer._parsers
assert layer._default_parser is not None
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_true")
def test_initialization_when_instrument_flag_enabled(self, tracer_provider_with_memory_exporter):
"""Test that layer enables when instrument flag is enabled."""
layer = ObservabilityLayer()
assert not layer._is_disabled
assert layer._tracer is not None
assert NodeType.TOOL in layer._parsers
assert layer._default_parser is not None
class TestObservabilityLayerNodeSpanLifecycle:
"""Test node span creation and lifecycle management."""
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_node_span_created_and_ended(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
):
"""Test that span is created on node start and ended on node end."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_llm_node)
layer.on_node_run_end(mock_llm_node, None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].name == mock_llm_node.title
assert spans[0].status.status_code == StatusCode.OK
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_node_error_recorded_in_span(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
):
"""Test that node execution errors are recorded in span."""
layer = ObservabilityLayer()
layer.on_graph_start()
error = ValueError("Test error")
layer.on_node_run_start(mock_llm_node)
layer.on_node_run_end(mock_llm_node, error)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].status.status_code == StatusCode.ERROR
assert len(spans[0].events) > 0
assert any("exception" in event.name.lower() for event in spans[0].events)
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_node_end_without_start_handled_gracefully(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
):
"""Test that ending a node without start doesn't crash."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_end(mock_llm_node, None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 0
class TestObservabilityLayerParserIntegration:
"""Test parser integration for different node types."""
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_default_parser_used_for_regular_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node
):
"""Test that default parser is used for non-tool nodes."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_start_node)
layer.on_node_run_end(mock_start_node, None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs["node.id"] == mock_start_node.id
assert attrs["node.execution_id"] == mock_start_node.execution_id
assert attrs["node.type"] == mock_start_node.node_type.value
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_tool_parser_used_for_tool_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_tool_node
):
"""Test that tool parser is used for tool nodes."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_tool_node)
layer.on_node_run_end(mock_tool_node, None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs["node.id"] == mock_tool_node.id
assert attrs["tool.provider.id"] == mock_tool_node._node_data.provider_id
assert attrs["tool.provider.type"] == mock_tool_node._node_data.provider_type.value
assert attrs["tool.name"] == mock_tool_node._node_data.tool_name
class TestObservabilityLayerGraphLifecycle:
"""Test graph lifecycle management."""
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_on_graph_start_clears_contexts(self, tracer_provider_with_memory_exporter, mock_llm_node):
"""Test that on_graph_start clears node contexts."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_llm_node)
assert len(layer._node_contexts) == 1
layer.on_graph_start()
assert len(layer._node_contexts) == 0
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_on_graph_end_with_no_unfinished_spans(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node
):
"""Test that on_graph_end handles normal completion."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_llm_node)
layer.on_node_run_end(mock_llm_node, None)
layer.on_graph_end(None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_on_graph_end_with_unfinished_spans_logs_warning(
self, tracer_provider_with_memory_exporter, mock_llm_node, caplog
):
"""Test that on_graph_end logs warning for unfinished spans."""
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_llm_node)
assert len(layer._node_contexts) == 1
layer.on_graph_end(None)
assert len(layer._node_contexts) == 0
assert "node spans were not properly ended" in caplog.text
class TestObservabilityLayerDisabledMode:
"""Test behavior when layer is disabled."""
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_disabled_mode_skips_node_start(self, memory_span_exporter, mock_start_node):
"""Test that disabled layer doesn't create spans on node start."""
layer = ObservabilityLayer()
assert layer._is_disabled
layer.on_graph_start()
layer.on_node_run_start(mock_start_node)
layer.on_node_run_end(mock_start_node, None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 0
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_disabled_mode_skips_node_end(self, memory_span_exporter, mock_llm_node):
"""Test that disabled layer doesn't process node end."""
layer = ObservabilityLayer()
assert layer._is_disabled
layer.on_node_run_end(mock_llm_node, None)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 0

View File

@ -110,6 +110,70 @@ class TestWebhookServiceUnit:
assert webhook_data["method"] == "POST"
assert webhook_data["body"]["raw"] == "raw text content"
def test_extract_octet_stream_body_uses_detected_mime(self):
"""Octet-stream uploads should rely on detected MIME type."""
app = Flask(__name__)
binary_content = b"plain text data"
with app.test_request_context(
"/webhook", method="POST", headers={"Content-Type": "application/octet-stream"}, data=binary_content
):
webhook_trigger = MagicMock()
mock_file = MagicMock()
mock_file.to_dict.return_value = {"file": "data"}
with (
patch.object(WebhookService, "_detect_binary_mimetype", return_value="text/plain") as mock_detect,
patch.object(WebhookService, "_create_file_from_binary") as mock_create,
):
mock_create.return_value = mock_file
body, files = WebhookService._extract_octet_stream_body(webhook_trigger)
assert body["raw"] == {"file": "data"}
assert files == {}
mock_detect.assert_called_once_with(binary_content)
mock_create.assert_called_once()
args = mock_create.call_args[0]
assert args[0] == binary_content
assert args[1] == "text/plain"
assert args[2] is webhook_trigger
def test_detect_binary_mimetype_uses_magic(self, monkeypatch):
"""python-magic output should be used when available."""
fake_magic = MagicMock()
fake_magic.from_buffer.return_value = "image/png"
monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic)
result = WebhookService._detect_binary_mimetype(b"binary data")
assert result == "image/png"
fake_magic.from_buffer.assert_called_once()
def test_detect_binary_mimetype_fallback_without_magic(self, monkeypatch):
"""Fallback MIME type should be used when python-magic is unavailable."""
monkeypatch.setattr("services.trigger.webhook_service.magic", None)
result = WebhookService._detect_binary_mimetype(b"binary data")
assert result == "application/octet-stream"
def test_detect_binary_mimetype_handles_magic_exception(self, monkeypatch):
"""Fallback MIME type should be used when python-magic raises an exception."""
try:
import magic as real_magic
except ImportError:
pytest.skip("python-magic is not installed")
fake_magic = MagicMock()
fake_magic.from_buffer.side_effect = real_magic.MagicException("magic error")
monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic)
with patch("services.trigger.webhook_service.logger") as mock_logger:
result = WebhookService._detect_binary_mimetype(b"binary data")
assert result == "application/octet-stream"
mock_logger.debug.assert_called_once()
def test_extract_webhook_data_invalid_json(self):
"""Test webhook data extraction with invalid JSON."""
app = Flask(__name__)

View File

@ -0,0 +1,34 @@
/**
* Shared mock for react-i18next
*
* Jest automatically uses this mock when react-i18next is imported in tests.
* The default behavior returns the translation key as-is, which is suitable
* for most test scenarios.
*
* For tests that need custom translations, you can override with jest.mock():
*
* @example
* jest.mock('react-i18next', () => ({
* useTranslation: () => ({
* t: (key: string) => {
* if (key === 'some.key') return 'Custom translation'
* return key
* },
* }),
* }))
*/
export const useTranslation = () => ({
t: (key: string) => key,
i18n: {
language: 'en',
changeLanguage: jest.fn(),
},
})
export const Trans = ({ children }: { children?: React.ReactNode }) => children
export const initReactI18next = {
type: '3rdParty',
init: jest.fn(),
}

View File

@ -4,12 +4,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth'
import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const replaceMock = jest.fn()
const backMock = jest.fn()

View File

@ -4,12 +4,6 @@ import '@testing-library/jest-dom'
import CommandSelector from '../../app/components/goto-anything/command-selector'
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('cmdk', () => ({
Command: {
Group: ({ children, className }: any) => <div className={className}>{children}</div>,

View File

@ -3,13 +3,6 @@ import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'
// Mock dependencies to isolate the SVG rendering issue
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('SVG Attribute Error Reproduction', () => {
// Capture console errors
const originalError = console.error

View File

@ -3,12 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CSVUploader, { type Props } from './csv-uploader'
import { ToastContext } from '@/app/components/base/toast'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('CSVUploader', () => {
const notify = jest.fn()
const updateFile = jest.fn()

View File

@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import OperationBtn from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@remixicon/react', () => ({
RiAddLine: (props: { className?: string }) => (
<svg data-testid='add-icon' className={props.className} />

View File

@ -2,12 +2,6 @@ import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfirmAddVar from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('../../base/var-highlight', () => ({
__esModule: true,
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,

View File

@ -3,12 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import EditModal from './edit-modal'
import type { ConversationHistoriesRole } from '@/models/debug'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@ -2,12 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import HistoryPanel from './history-panel'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockDocLink = jest.fn(() => 'doc-link')
jest.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,

View File

@ -6,12 +6,6 @@ import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
import { type PromptItem, PromptRole, type PromptVariable } from '@/models/debug'
import { AppModeEnum, ModelModeType } from '@/types/app'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
type DebugConfiguration = {
isAdvancedMode: boolean
currentAdvancedPrompt: PromptItem | PromptItem[]

View File

@ -5,12 +5,6 @@ jest.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ConfigSelect Component', () => {
const defaultProps = {
options: ['Option 1', 'Option 2'],

View File

@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ContrlBtnGroup from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ContrlBtnGroup', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@ -51,12 +51,6 @@ const mockFiles: FileEntity[] = [
},
]
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/context/debug-configuration', () => ({
__esModule: true,
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),

View File

@ -0,0 +1,287 @@
import { fireEvent, render, screen } from '@testing-library/react'
import CreateAppTemplateDialog from './index'
// Mock external dependencies (not base components)
jest.mock('./app-list', () => {
return function MockAppList({
onCreateFromBlank,
onSuccess,
}: {
onCreateFromBlank?: () => void
onSuccess: () => void
}) {
return (
<div data-testid="app-list">
<button data-testid="app-list-success" onClick={onSuccess}>
Success
</button>
{onCreateFromBlank && (
<button data-testid="create-from-blank" onClick={onCreateFromBlank}>
Create from Blank
</button>
)}
</div>
)
}
})
jest.mock('ahooks', () => ({
useKeyPress: jest.fn((key: string, callback: () => void) => {
// Mock implementation for testing
return jest.fn()
}),
}))
describe('CreateAppTemplateDialog', () => {
const defaultProps = {
show: false,
onSuccess: jest.fn(),
onClose: jest.fn(),
onCreateFromBlank: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<CreateAppTemplateDialog {...defaultProps} />)
// FullScreenModal should not render any content when open is false
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render modal when show is true', () => {
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
// FullScreenModal renders with role="dialog"
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByTestId('app-list')).toBeInTheDocument()
})
it('should render create from blank button when onCreateFromBlank is provided', () => {
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
expect(screen.getByTestId('create-from-blank')).toBeInTheDocument()
})
it('should not render create from blank button when onCreateFromBlank is not provided', () => {
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
expect(screen.queryByTestId('create-from-blank')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass show prop to FullScreenModal', () => {
const { rerender } = render(<CreateAppTemplateDialog {...defaultProps} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
rerender(<CreateAppTemplateDialog {...defaultProps} show={true} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should pass closable prop to FullScreenModal', () => {
// Since the FullScreenModal is always rendered with closable=true
// we can verify that the modal renders with the proper structure
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
// Verify that the modal has the proper dialog structure
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})
describe('User Interactions', () => {
it('should handle close interactions', () => {
const mockOnClose = jest.fn()
render(<CreateAppTemplateDialog {...defaultProps} show={true} onClose={mockOnClose} />)
// Test that the modal is rendered
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
// Test that AppList component renders (child component interactions)
expect(screen.getByTestId('app-list')).toBeInTheDocument()
expect(screen.getByTestId('app-list-success')).toBeInTheDocument()
})
it('should call both onSuccess and onClose when app list success is triggered', () => {
const mockOnSuccess = jest.fn()
const mockOnClose = jest.fn()
render(<CreateAppTemplateDialog
{...defaultProps}
show={true}
onSuccess={mockOnSuccess}
onClose={mockOnClose}
/>)
fireEvent.click(screen.getByTestId('app-list-success'))
expect(mockOnSuccess).toHaveBeenCalledTimes(1)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onCreateFromBlank when create from blank is clicked', () => {
const mockOnCreateFromBlank = jest.fn()
render(<CreateAppTemplateDialog
{...defaultProps}
show={true}
onCreateFromBlank={mockOnCreateFromBlank}
/>)
fireEvent.click(screen.getByTestId('create-from-blank'))
expect(mockOnCreateFromBlank).toHaveBeenCalledTimes(1)
})
})
describe('useKeyPress Integration', () => {
it('should set up ESC key listener when modal is shown', () => {
const { useKeyPress } = require('ahooks')
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
expect(useKeyPress).toHaveBeenCalledWith('esc', expect.any(Function))
})
it('should handle ESC key press to close modal', () => {
const { useKeyPress } = require('ahooks')
let capturedCallback: (() => void) | undefined
useKeyPress.mockImplementation((key: string, callback: () => void) => {
if (key === 'esc')
capturedCallback = callback
return jest.fn()
})
const mockOnClose = jest.fn()
render(<CreateAppTemplateDialog
{...defaultProps}
show={true}
onClose={mockOnClose}
/>)
expect(capturedCallback).toBeDefined()
expect(typeof capturedCallback).toBe('function')
// Simulate ESC key press
capturedCallback?.()
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when ESC key is pressed and modal is not shown', () => {
const { useKeyPress } = require('ahooks')
let capturedCallback: (() => void) | undefined
useKeyPress.mockImplementation((key: string, callback: () => void) => {
if (key === 'esc')
capturedCallback = callback
return jest.fn()
})
const mockOnClose = jest.fn()
render(<CreateAppTemplateDialog
{...defaultProps}
show={false} // Modal not shown
onClose={mockOnClose}
/>)
// The callback should still be created but not execute onClose
expect(capturedCallback).toBeDefined()
// Simulate ESC key press
capturedCallback?.()
// onClose should not be called because modal is not shown
expect(mockOnClose).not.toHaveBeenCalled()
})
})
describe('Callback Dependencies', () => {
it('should create stable callback reference for ESC key handler', () => {
const { useKeyPress } = require('ahooks')
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
// Verify that useKeyPress was called with a function
const calls = useKeyPress.mock.calls
expect(calls.length).toBeGreaterThan(0)
expect(calls[0][0]).toBe('esc')
expect(typeof calls[0][1]).toBe('function')
})
})
describe('Edge Cases', () => {
it('should handle null props gracefully', () => {
expect(() => {
render(<CreateAppTemplateDialog
show={true}
onSuccess={jest.fn()}
onClose={jest.fn()}
// onCreateFromBlank is undefined
/>)
}).not.toThrow()
})
it('should handle undefined props gracefully', () => {
expect(() => {
render(<CreateAppTemplateDialog
show={true}
onSuccess={jest.fn()}
onClose={jest.fn()}
onCreateFromBlank={undefined}
/>)
}).not.toThrow()
})
it('should handle rapid show/hide toggles', () => {
// Test initial state
const { unmount } = render(<CreateAppTemplateDialog {...defaultProps} show={false} />)
unmount()
// Test show state
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Test hide state
render(<CreateAppTemplateDialog {...defaultProps} show={false} />)
// Due to transition animations, we just verify the component handles the prop change
expect(() => render(<CreateAppTemplateDialog {...defaultProps} show={false} />)).not.toThrow()
})
it('should handle missing optional onCreateFromBlank prop', () => {
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
expect(() => {
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
}).not.toThrow()
expect(screen.getByTestId('app-list')).toBeInTheDocument()
expect(screen.queryByTestId('create-from-blank')).not.toBeInTheDocument()
})
it('should work with all required props only', () => {
const requiredProps = {
show: true,
onSuccess: jest.fn(),
onClose: jest.fn(),
}
expect(() => {
render(<CreateAppTemplateDialog {...requiredProps} />)
}).not.toThrow()
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByTestId('app-list')).toBeInTheDocument()
})
})
})

View File

@ -18,12 +18,6 @@ import type { App, AppIconType, AppModeEnum } from '@/types/app'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockRouterPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({

View File

@ -16,12 +16,6 @@ import type { QueryParam } from './index'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockTrackEvent = jest.fn()
jest.mock('@/app/components/base/amplitude/utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),

View File

@ -49,13 +49,6 @@ jest.mock('next/navigation', () => ({
}),
}))
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
jest.mock('next/link', () => ({
__esModule: true,
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,

View File

@ -22,12 +22,6 @@ import { APP_PAGE_LIMIT } from '@/config'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockRouterPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({

View File

@ -15,12 +15,6 @@ import { Theme } from '@/types/app'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let mockTheme = Theme.light
jest.mock('@/hooks/use-theme', () => ({
__esModule: true,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Empty from './empty'
describe('Empty', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the no apps found message', () => {
render(<Empty />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
rerender(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,94 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Footer from './footer'
describe('Footer', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
it('should display the community heading', () => {
render(<Footer />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.join')).toBeInTheDocument()
})
it('should display the community intro text', () => {
render(<Footer />)
expect(screen.getByText('app.communityIntro')).toBeInTheDocument()
})
})
describe('Links', () => {
it('should render GitHub link with correct href', () => {
const { container } = render(<Footer />)
const githubLink = container.querySelector('a[href="https://github.com/langgenius/dify"]')
expect(githubLink).toBeInTheDocument()
})
it('should render Discord link with correct href', () => {
const { container } = render(<Footer />)
const discordLink = container.querySelector('a[href="https://discord.gg/FngNHpbcY7"]')
expect(discordLink).toBeInTheDocument()
})
it('should render Forum link with correct href', () => {
const { container } = render(<Footer />)
const forumLink = container.querySelector('a[href="https://forum.dify.ai"]')
expect(forumLink).toBeInTheDocument()
})
it('should have 3 community links', () => {
render(<Footer />)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(3)
})
it('should open links in new tab', () => {
render(<Footer />)
const links = screen.getAllByRole('link')
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})
describe('Styling', () => {
it('should have correct footer styling', () => {
render(<Footer />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('relative', 'shrink-0', 'grow-0')
})
it('should have gradient text styling on heading', () => {
render(<Footer />)
const heading = screen.getByText('app.join')
expect(heading).toHaveClass('text-gradient')
})
})
describe('Icons', () => {
it('should render icons within links', () => {
const { container } = render(<Footer />)
const svgElements = container.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThanOrEqual(3)
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
rerender(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,363 @@
/**
* Test suite for useAppsQueryState hook
*
* This hook manages app filtering state through URL search parameters, enabling:
* - Bookmarkable filter states (users can share URLs with specific filters active)
* - Browser history integration (back/forward buttons work with filters)
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
*
* The hook syncs local filter state with URL search parameters, making filter
* navigation persistent and shareable across sessions.
*/
import { act, renderHook } from '@testing-library/react'
// Mock Next.js navigation hooks
const mockPush = jest.fn()
const mockPathname = '/apps'
let mockSearchParams = new URLSearchParams()
jest.mock('next/navigation', () => ({
usePathname: jest.fn(() => mockPathname),
useRouter: jest.fn(() => ({
push: mockPush,
})),
useSearchParams: jest.fn(() => mockSearchParams),
}))
// Import the hook after mocks are set up
import useAppsQueryState from './use-apps-query-state'
describe('useAppsQueryState', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSearchParams = new URLSearchParams()
})
describe('Basic functionality', () => {
it('should return query object and setQuery function', () => {
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query).toBeDefined()
expect(typeof result.current.setQuery).toBe('function')
})
it('should initialize with empty query when no search params exist', () => {
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
})
describe('Parsing search params', () => {
it('should parse tagIDs from URL', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2;tag3')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
})
it('should parse single tagID from URL', () => {
mockSearchParams.set('tagIDs', 'single-tag')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['single-tag'])
})
it('should parse keywords from URL', () => {
mockSearchParams.set('keywords', 'search term')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBe('search term')
})
it('should parse isCreatedByMe as true from URL', () => {
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should parse isCreatedByMe as false for other values', () => {
mockSearchParams.set('isCreatedByMe', 'false')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.isCreatedByMe).toBe(false)
})
it('should parse all params together', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
mockSearchParams.set('keywords', 'test')
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
describe('Updating query state', () => {
it('should update keywords via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'new search' })
})
expect(result.current.query.keywords).toBe('new search')
})
it('should update tagIDs via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
})
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
})
it('should update isCreatedByMe via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: true })
})
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should support partial updates via callback', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'initial' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('initial')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
describe('URL synchronization', () => {
it('should sync keywords to URL', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'search' })
})
// Wait for useEffect to run
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('keywords=search'),
{ scroll: false },
)
})
it('should sync tagIDs to URL with semicolon separator', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('tagIDs=tag1%3Btag2'),
{ scroll: false },
)
})
it('should sync isCreatedByMe to URL', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: true })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('isCreatedByMe=true'),
{ scroll: false },
)
})
it('should remove keywords from URL when empty', async () => {
mockSearchParams.set('keywords', 'existing')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: '' })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
// Should be called without keywords param
expect(mockPush).toHaveBeenCalled()
})
it('should remove tagIDs from URL when empty array', async () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: [] })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalled()
})
it('should remove isCreatedByMe from URL when false', async () => {
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: false })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalled()
})
})
describe('Edge cases', () => {
it('should handle empty tagIDs string in URL', () => {
// NOTE: This test documents current behavior where ''.split(';') returns ['']
// This could potentially cause filtering issues as it's treated as a tag with empty name
// rather than absence of tags. Consider updating parseParams if this is problematic.
mockSearchParams.set('tagIDs', '')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual([''])
})
it('should handle empty keywords', () => {
mockSearchParams.set('keywords', '')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBeUndefined()
})
it('should handle undefined tagIDs', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: undefined })
})
expect(result.current.query.tagIDs).toBeUndefined()
})
it('should handle special characters in keywords', () => {
// Use URLSearchParams constructor to properly simulate URL decoding behavior
// URLSearchParams.get() decodes URL-encoded characters
mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBe('test with spaces')
})
})
describe('Memoization', () => {
it('should return memoized object reference when query unchanged', () => {
const { result, rerender } = renderHook(() => useAppsQueryState())
const firstResult = result.current
rerender()
const secondResult = result.current
expect(firstResult.query).toBe(secondResult.query)
})
it('should return new object reference when query changes', () => {
const { result } = renderHook(() => useAppsQueryState())
const firstQuery = result.current.query
act(() => {
result.current.setQuery({ keywords: 'changed' })
})
expect(result.current.query).not.toBe(firstQuery)
})
})
describe('Integration scenarios', () => {
it('should handle sequential updates', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'first' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('first')
expect(result.current.query.tagIDs).toEqual(['tag1'])
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should clear all filters', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
mockSearchParams.set('keywords', 'search')
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({
tagIDs: undefined,
keywords: undefined,
isCreatedByMe: false,
})
})
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
})
})

View File

@ -0,0 +1,493 @@
/**
* Test suite for useDSLDragDrop hook
*
* This hook provides drag-and-drop functionality for DSL files, enabling:
* - File drag detection with visual feedback (dragging state)
* - YAML/YML file filtering (only accepts .yaml and .yml files)
* - Enable/disable toggle for conditional drag-and-drop
* - Cleanup on unmount (removes event listeners)
*/
import { act, renderHook } from '@testing-library/react'
import { useDSLDragDrop } from './use-dsl-drag-drop'
describe('useDSLDragDrop', () => {
let container: HTMLDivElement
let mockOnDSLFileDropped: jest.Mock
beforeEach(() => {
jest.clearAllMocks()
container = document.createElement('div')
document.body.appendChild(container)
mockOnDSLFileDropped = jest.fn()
})
afterEach(() => {
document.body.removeChild(container)
})
// Helper to create drag events
const createDragEvent = (type: string, files: File[] = []) => {
const dataTransfer = {
types: files.length > 0 ? ['Files'] : [],
files,
}
const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent
Object.defineProperty(event, 'dataTransfer', {
value: dataTransfer,
writable: false,
})
Object.defineProperty(event, 'preventDefault', {
value: jest.fn(),
writable: false,
})
Object.defineProperty(event, 'stopPropagation', {
value: jest.fn(),
writable: false,
})
return event
}
// Helper to create a mock file
const createMockFile = (name: string) => {
return new File(['content'], name, { type: 'application/x-yaml' })
}
describe('Basic functionality', () => {
it('should return dragging state', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
expect(result.current.dragging).toBe(false)
})
it('should initialize with dragging as false', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
expect(result.current.dragging).toBe(false)
})
})
describe('Drag events', () => {
it('should set dragging to true on dragenter with files', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.yaml')
const event = createDragEvent('dragenter', [file])
act(() => {
container.dispatchEvent(event)
})
expect(result.current.dragging).toBe(true)
})
it('should not set dragging on dragenter without files', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const event = createDragEvent('dragenter', [])
act(() => {
container.dispatchEvent(event)
})
expect(result.current.dragging).toBe(false)
})
it('should handle dragover event', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const event = createDragEvent('dragover')
act(() => {
container.dispatchEvent(event)
})
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should set dragging to false on dragleave when leaving container', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then leave with null relatedTarget (leaving container)
const leaveEvent = createDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', {
value: null,
writable: false,
})
act(() => {
container.dispatchEvent(leaveEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should not set dragging to false on dragleave when within container', () => {
const containerRef = { current: container }
const childElement = document.createElement('div')
container.appendChild(childElement)
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then leave but to a child element
const leaveEvent = createDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', {
value: childElement,
writable: false,
})
act(() => {
container.dispatchEvent(leaveEvent)
})
expect(result.current.dragging).toBe(true)
container.removeChild(childElement)
})
})
describe('Drop functionality', () => {
it('should call onDSLFileDropped for .yaml file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.yaml')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
})
it('should call onDSLFileDropped for .yml file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.yml')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
})
it('should call onDSLFileDropped for uppercase .YAML file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.YAML')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
})
it('should not call onDSLFileDropped for non-yaml file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.json')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
})
it('should set dragging to false on drop', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then drop
const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should handle drop with no dataTransfer', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const event = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent
Object.defineProperty(event, 'dataTransfer', {
value: null,
writable: false,
})
Object.defineProperty(event, 'preventDefault', {
value: jest.fn(),
writable: false,
})
Object.defineProperty(event, 'stopPropagation', {
value: jest.fn(),
writable: false,
})
act(() => {
container.dispatchEvent(event)
})
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
})
it('should handle drop with empty files array', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const dropEvent = createDragEvent('drop', [])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
})
it('should only process the first file when multiple files are dropped', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file1 = createMockFile('test1.yaml')
const file2 = createMockFile('test2.yaml')
const dropEvent = createDragEvent('drop', [file1, file2])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledTimes(1)
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file1)
})
})
describe('Enabled prop', () => {
it('should not add event listeners when enabled is false', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
enabled: false,
}),
)
const file = createMockFile('test.yaml')
const enterEvent = createDragEvent('dragenter', [file])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should return dragging as false when enabled is false even if state is true', () => {
const containerRef = { current: container }
const { result, rerender } = renderHook(
({ enabled }) =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
enabled,
}),
{ initialProps: { enabled: true } },
)
// Set dragging state
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Disable the hook
rerender({ enabled: false })
expect(result.current.dragging).toBe(false)
})
it('should default enabled to true', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
})
})
describe('Cleanup', () => {
it('should remove event listeners on unmount', () => {
const containerRef = { current: container }
const removeEventListenerSpy = jest.spyOn(container, 'removeEventListener')
const { unmount } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
removeEventListenerSpy.mockRestore()
})
})
describe('Edge cases', () => {
it('should handle null containerRef', () => {
const containerRef = { current: null }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
expect(result.current.dragging).toBe(false)
})
it('should handle containerRef changing to null', () => {
const containerRef = { current: container as HTMLDivElement | null }
const { result, rerender } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
containerRef.current = null
rerender()
expect(result.current.dragging).toBe(false)
})
})
})

View File

@ -0,0 +1,106 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
// Track mock calls
let documentTitleCalls: string[] = []
let educationInitCalls: number = 0
// Mock useDocumentTitle hook
jest.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: (title: string) => {
documentTitleCalls.push(title)
},
}))
// Mock useEducationInit hook
jest.mock('@/app/education-apply/hooks', () => ({
useEducationInit: () => {
educationInitCalls++
},
}))
// Mock List component
jest.mock('./list', () => ({
__esModule: true,
default: () => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
},
}))
// Import after mocks
import Apps from './index'
describe('Apps', () => {
beforeEach(() => {
jest.clearAllMocks()
documentTitleCalls = []
educationInitCalls = 0
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
})
it('should render List component', () => {
render(<Apps />)
expect(screen.getByText('Apps List')).toBeInTheDocument()
})
it('should have correct container structure', () => {
const { container } = render(<Apps />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col')
})
})
describe('Hooks', () => {
it('should call useDocumentTitle with correct title', () => {
render(<Apps />)
expect(documentTitleCalls).toContain('common.menus.apps')
})
it('should call useEducationInit', () => {
render(<Apps />)
expect(educationInitCalls).toBeGreaterThan(0)
})
})
describe('Integration', () => {
it('should render full component tree', () => {
render(<Apps />)
// Verify container exists
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
// Verify hooks were called
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
})
it('should handle multiple renders', () => {
const { rerender } = render(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
rerender(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have overflow-y-auto class', () => {
const { container } = render(<Apps />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('overflow-y-auto')
})
it('should have background styling', () => {
const { container } = render(<Apps />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('bg-background-body')
})
})
})

View File

@ -0,0 +1,573 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
// Mock next/navigation
const mockReplace = jest.fn()
const mockRouter = { replace: mockReplace }
jest.mock('next/navigation', () => ({
useRouter: () => mockRouter,
}))
// Mock app context
const mockIsCurrentWorkspaceEditor = jest.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = jest.fn(() => false)
jest.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
}),
}))
// Mock global public store
jest.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
// Mock custom hooks
const mockSetQuery = jest.fn()
jest.mock('./hooks/use-apps-query-state', () => ({
__esModule: true,
default: () => ({
query: { tagIDs: [], keywords: '', isCreatedByMe: false },
setQuery: mockSetQuery,
}),
}))
jest.mock('./hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: () => ({
dragging: false,
}),
}))
const mockSetActiveTab = jest.fn()
jest.mock('@/hooks/use-tab-searchparams', () => ({
useTabSearchParams: () => ['all', mockSetActiveTab],
}))
// Mock service hooks
const mockRefetch = jest.fn()
jest.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: {
pages: [{
data: [
{
id: 'app-1',
name: 'Test App 1',
description: 'Description 1',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
tags: [],
author_name: 'Author 1',
created_at: 1704067200,
updated_at: 1704153600,
},
{
id: 'app-2',
name: 'Test App 2',
description: 'Description 2',
mode: AppModeEnum.WORKFLOW,
icon: '⚙️',
icon_type: 'emoji',
icon_background: '#E4FBCC',
tags: [],
author_name: 'Author 2',
created_at: 1704067200,
updated_at: 1704153600,
},
],
total: 2,
}],
},
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
}))
// Mock tag store
jest.mock('@/app/components/base/tag-management/store', () => ({
useStore: () => false,
}))
// Mock config
jest.mock('@/config', () => ({
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
}))
// Mock pay hook
jest.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
// Mock debounce hook
jest.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => ({ run: fn }),
}))
// Mock dynamic imports
jest.mock('next/dynamic', () => {
const React = require('react')
return (importFn: () => Promise<any>) => {
const fnString = importFn.toString()
if (fnString.includes('tag-management')) {
return function MockTagManagement() {
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
)
}
}
return () => null
}
})
/**
* Mock child components for focused List component testing.
* These mocks isolate the List component's behavior from its children.
* Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests.
*/
jest.mock('./app-card', () => ({
__esModule: true,
default: ({ app }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
},
}))
jest.mock('./new-app-card', () => {
const React = require('react')
return React.forwardRef((_props: any, _ref: any) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
})
})
jest.mock('./empty', () => ({
__esModule: true,
default: () => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
},
}))
jest.mock('./footer', () => ({
__esModule: true,
default: () => {
const React = require('react')
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
},
}))
/**
* Mock base components that have deep dependency chains or require controlled test behavior.
*
* Per frontend testing skills (mocking.md), we generally should NOT mock base components.
* However, the following require mocking due to:
* - Deep dependency chains importing ES modules (like ky) incompatible with Jest
* - Need for controlled interaction behavior in tests (onChange, onClear handlers)
* - Complex internal state that would make tests flaky
*
* These mocks preserve the component's props interface to test List's integration correctly.
*/
jest.mock('@/app/components/base/tab-slider-new', () => ({
__esModule: true,
default: ({ value, onChange, options }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'tab-slider', 'role': 'tablist' },
options.map((opt: any) =>
React.createElement('button', {
'key': opt.value,
'data-testid': `tab-${opt.value}`,
'role': 'tab',
'aria-selected': value === opt.value,
'onClick': () => onChange(opt.value),
}, opt.text),
),
)
},
}))
jest.mock('@/app/components/base/input', () => ({
__esModule: true,
default: ({ value, onChange, onClear }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'search-input' },
React.createElement('input', {
'data-testid': 'search-input-field',
'role': 'searchbox',
'value': value || '',
onChange,
}),
React.createElement('button', {
'data-testid': 'clear-search',
'aria-label': 'Clear search',
'onClick': onClear,
}, 'Clear'),
)
},
}))
jest.mock('@/app/components/base/tag-management/filter', () => ({
__esModule: true,
default: ({ value, onChange }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'tag-filter', 'role': 'listbox' },
React.createElement('button', {
'data-testid': 'add-tag-filter',
'onClick': () => onChange([...value, 'new-tag']),
}, 'Add Tag'),
)
},
}))
jest.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
__esModule: true,
default: ({ label, isChecked, onChange }: any) => {
const React = require('react')
return React.createElement('label', { 'data-testid': 'created-by-me-checkbox' },
React.createElement('input', {
'type': 'checkbox',
'role': 'checkbox',
'checked': isChecked,
'aria-checked': isChecked,
onChange,
'data-testid': 'created-by-me-input',
}),
label,
)
},
}))
// Import after mocks
import List from './list'
describe('List', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
render(<List />)
expect(screen.getByTestId('tab-all')).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
})
it('should render search input', () => {
render(<List />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
render(<List />)
expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
render(<List />)
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
render(<List />)
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
render(<List />)
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should call setActiveTab when tab is clicked', () => {
render(<List />)
fireEvent.click(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`))
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should call setActiveTab for all tab', () => {
render(<List />)
fireEvent.click(screen.getByTestId('tab-all'))
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
render(<List />)
expect(screen.getByTestId('search-input-field')).toBeInTheDocument()
})
it('should handle search input change', () => {
render(<List />)
const input = screen.getByTestId('search-input-field')
fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetQuery).toHaveBeenCalled()
})
it('should clear search when clear button is clicked', () => {
render(<List />)
fireEvent.click(screen.getByTestId('clear-search'))
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should handle tag filter change', () => {
render(<List />)
fireEvent.click(screen.getByTestId('add-tag-filter'))
// Tag filter change triggers debounced setTagIDs
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
render(<List />)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
render(<List />)
const checkbox = screen.getByTestId('created-by-me-input')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Redirect', () => {
it('should redirect dataset operators to datasets page', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
render(<List />)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
render(<List />)
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
render(<List />)
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
render(<List />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
render(<List />)
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
render(<List />)
expect(screen.getByTestId('tab-all')).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
})
it('should call setActiveTab for each app type', () => {
render(<List />)
const appModes = [
AppModeEnum.WORKFLOW,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
]
appModes.forEach((mode) => {
fireEvent.click(screen.getByTestId(`tab-${mode}`))
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
})
})
})
describe('Search and Filter Integration', () => {
it('should display search input with correct attributes', () => {
render(<List />)
const input = screen.getByTestId('search-input-field')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('value', '')
})
it('should have tag filter component', () => {
render(<List />)
const tagFilter = screen.getByTestId('tag-filter')
expect(tagFilter).toBeInTheDocument()
})
it('should display created by me label', () => {
render(<List />)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
render(<List />)
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
render(<List />)
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Additional Coverage Tests
// --------------------------------------------------------------------------
describe('Additional Coverage', () => {
it('should render dragging state overlay when dragging', () => {
// Test dragging state is handled
const { container } = render(<List />)
// Component should render successfully
expect(container).toBeInTheDocument()
})
it('should handle app mode filter in query params', () => {
// Test that different modes are handled in query
render(<List />)
const workflowTab = screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)
fireEvent.click(workflowTab)
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should render new app card for editors', () => {
render(<List />)
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,287 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
// Mock next/navigation
const mockReplace = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
useSearchParams: () => new URLSearchParams(),
}))
// Mock provider context
const mockOnPlanInfoChanged = jest.fn()
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
// Mock next/dynamic to immediately resolve components
jest.mock('next/dynamic', () => {
const React = require('react')
return (importFn: () => Promise<any>) => {
const fnString = importFn.toString()
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-app-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'),
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'),
React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template'),
)
}
}
if (fnString.includes('create-app-dialog')) {
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-template-dialog' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'),
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'),
React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank'),
)
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'),
)
}
}
return () => null
}
})
// Mock CreateFromDSLModalTab enum
jest.mock('@/app/components/app/create-from-dsl-modal', () => ({
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
},
}))
// Import after mocks
import CreateAppCard from './new-app-card'
describe('CreateAppCard', () => {
const defaultRef = { current: null } as React.RefObject<HTMLDivElement | null>
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard ref={defaultRef} />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
it('should render three create buttons', () => {
render(<CreateAppCard ref={defaultRef} />)
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
})
it('should render all buttons as clickable', () => {
render(<CreateAppCard ref={defaultRef} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
buttons.forEach((button) => {
expect(button).not.toBeDisabled()
})
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<CreateAppCard ref={defaultRef} className="custom-class" />,
)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('custom-class')
})
it('should render with selectedAppType prop', () => {
render(<CreateAppCard ref={defaultRef} selectedAppType="chat" />)
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
})
describe('User Interactions - Create App Modal', () => {
it('should open create app modal when clicking Start from Blank', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
it('should close create app modal when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-create-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on create app success', () => {
const mockOnSuccess = jest.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('success-create-modal'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should switch from create modal to template dialog', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('to-template-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
})
})
describe('User Interactions - Template Dialog', () => {
it('should open template dialog when clicking Start from Template', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
})
it('should close template dialog when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-template-dialog'))
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on template success', () => {
const mockOnSuccess = jest.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('success-template-dialog'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should switch from template dialog to create modal', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('to-blank-modal'))
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
describe('User Interactions - DSL Import Modal', () => {
it('should open DSL modal when clicking Import DSL', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on DSL import success', () => {
const mockOnSuccess = jest.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
})
describe('Styling', () => {
it('should have correct card container styling', () => {
const { container } = render(<CreateAppCard ref={defaultRef} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[160px]', 'rounded-xl')
})
it('should have proper button styling', () => {
render(<CreateAppCard ref={defaultRef} />)
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button).toHaveClass('cursor-pointer')
})
})
})
describe('Edge Cases', () => {
it('should handle multiple modal opens/closes', () => {
render(<CreateAppCard ref={defaultRef} />)
// Open and close create modal
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('close-create-modal'))
// Open and close template dialog
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('close-template-dialog'))
// Open and close DSL modal
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('close-dsl-modal'))
// No modals should be visible
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should handle onSuccess not being provided', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
// This should not throw an error
expect(() => {
fireEvent.click(screen.getByTestId('success-create-modal'))
}).not.toThrow()
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
})
})
})

View File

@ -6,13 +6,6 @@ import type { IDrawerProps } from './index'
// Capture dialog onClose for testing
let capturedDialogOnClose: (() => void) | null = null
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock @headlessui/react
jest.mock('@headlessui/react', () => ({
Dialog: ({ children, open, onClose, className, unmount }: {

View File

@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Label from './label'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Label Component', () => {
const defaultProps = {
htmlFor: 'test-input',

View File

@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InputNumber } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('InputNumber Component', () => {
const defaultProps = {
onChange: jest.fn(),

View File

@ -1,12 +1,6 @@
import { render, screen } from '@testing-library/react'
import AnnotationFull from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,

View File

@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,

View File

@ -5,12 +5,6 @@ import PlanUpgradeModal from './index'
const mockSetShowPricingModal = jest.fn()
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/modal', () => {
const MockModal = ({ isShow, children }: { isShow: boolean; children: React.ReactNode }) => (
isShow ? <div data-testid="plan-upgrade-modal">{children}</div> : null

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,641 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import type { DocumentItem } from '@/models/datasets'
import PreviewDocumentPicker from './preview-document-picker'
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'dataset.preprocessDocument' && params?.num)
return `${params.num} files`
return key
},
}),
}))
// Mock portal-to-follow-elem - always render content for testing
jest.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: {
children: React.ReactNode
open?: boolean
}) => (
<div data-testid="portal-elem" data-open={String(open || false)}>
{children}
</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: {
children: React.ReactNode
onClick?: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
// Always render content to allow testing document selection
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}))
// Mock icons
jest.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <span data-testid="arrow-icon"></span>,
RiFile3Fill: () => <span data-testid="file-icon">📄</span>,
RiFileCodeFill: () => <span data-testid="file-code-icon">📄</span>,
RiFileExcelFill: () => <span data-testid="file-excel-icon">📄</span>,
RiFileGifFill: () => <span data-testid="file-gif-icon">📄</span>,
RiFileImageFill: () => <span data-testid="file-image-icon">📄</span>,
RiFileMusicFill: () => <span data-testid="file-music-icon">📄</span>,
RiFilePdf2Fill: () => <span data-testid="file-pdf-icon">📄</span>,
RiFilePpt2Fill: () => <span data-testid="file-ppt-icon">📄</span>,
RiFileTextFill: () => <span data-testid="file-text-icon">📄</span>,
RiFileVideoFill: () => <span data-testid="file-video-icon">📄</span>,
RiFileWordFill: () => <span data-testid="file-word-icon">📄</span>,
RiMarkdownFill: () => <span data-testid="file-markdown-icon">📄</span>,
}))
// Factory function to create mock DocumentItem
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
name: 'Test Document',
extension: 'txt',
...overrides,
})
// Factory function to create multiple document items
const createMockDocumentList = (count: number): DocumentItem[] => {
return Array.from({ length: count }, (_, index) =>
createMockDocumentItem({
id: `doc-${index + 1}`,
name: `Document ${index + 1}`,
extension: index % 2 === 0 ? 'pdf' : 'txt',
}),
)
}
// Factory function to create default props
const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {}) => ({
value: createMockDocumentItem({ id: 'selected-doc', name: 'Selected Document' }),
files: createMockDocumentList(3),
onChange: jest.fn(),
...overrides,
})
// Helper to render component with default props
const renderComponent = (props: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {}) => {
const defaultProps = createDefaultProps(props)
return {
...render(<PreviewDocumentPicker {...defaultProps} />),
props: defaultProps,
}
}
describe('PreviewDocumentPicker', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', () => {
renderComponent()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should render document name from value prop', () => {
renderComponent({
value: createMockDocumentItem({ name: 'My Document' }),
})
expect(screen.getByText('My Document')).toBeInTheDocument()
})
it('should render placeholder when name is empty', () => {
renderComponent({
value: createMockDocumentItem({ name: '' }),
})
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render placeholder when name is undefined', () => {
renderComponent({
value: { id: 'doc-1', extension: 'txt' } as DocumentItem,
})
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render arrow icon', () => {
renderComponent()
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
})
it('should render file icon', () => {
renderComponent({
value: createMockDocumentItem({ extension: 'txt' }),
files: [], // Use empty files to avoid duplicate icons
})
expect(screen.getByTestId('file-text-icon')).toBeInTheDocument()
})
it('should render pdf icon for pdf extension', () => {
renderComponent({
value: createMockDocumentItem({ extension: 'pdf' }),
files: [], // Use empty files to avoid duplicate icons
})
expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument()
})
})
// Tests for props handling
describe('Props', () => {
it('should accept required props', () => {
const props = createDefaultProps()
render(<PreviewDocumentPicker {...props} />)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should apply className to trigger element', () => {
renderComponent({ className: 'custom-class' })
const trigger = screen.getByTestId('portal-trigger')
const innerDiv = trigger.querySelector('.custom-class')
expect(innerDiv).toBeInTheDocument()
})
it('should handle empty files array', () => {
// Component should render without crashing with empty files
renderComponent({ files: [] })
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle single file', () => {
// Component should accept single file
renderComponent({
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
})
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle multiple files', () => {
// Component should accept multiple files
renderComponent({
files: createMockDocumentList(5),
})
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should use value.extension for file icon', () => {
renderComponent({
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
})
expect(screen.getByTestId('file-word-icon')).toBeInTheDocument()
})
})
// Tests for state management
describe('State Management', () => {
it('should initialize with popup closed', () => {
renderComponent()
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
})
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
})
it('should render portal content for document selection', () => {
renderComponent()
// Portal content is always rendered in our mock for testing
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
})
// Tests for callback stability and memoization
describe('Callback Stability', () => {
it('should maintain stable onChange callback when value changes', () => {
const onChange = jest.fn()
const value1 = createMockDocumentItem({ id: 'doc-1', name: 'Doc 1' })
const value2 = createMockDocumentItem({ id: 'doc-2', name: 'Doc 2' })
const { rerender } = render(
<PreviewDocumentPicker
value={value1}
files={createMockDocumentList(3)}
onChange={onChange}
/>,
)
rerender(
<PreviewDocumentPicker
value={value2}
files={createMockDocumentList(3)}
onChange={onChange}
/>,
)
expect(screen.getByText('Doc 2')).toBeInTheDocument()
})
it('should use updated onChange callback after rerender', () => {
const onChange1 = jest.fn()
const onChange2 = jest.fn()
const value = createMockDocumentItem()
const files = createMockDocumentList(3)
const { rerender } = render(
<PreviewDocumentPicker value={value} files={files} onChange={onChange1} />,
)
rerender(
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
// Tests for component memoization
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect((PreviewDocumentPicker as any).$$typeof).toBeDefined()
})
it('should not re-render when props are the same', () => {
const onChange = jest.fn()
const value = createMockDocumentItem()
const files = createMockDocumentList(3)
const { rerender } = render(
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
)
rerender(
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
// Tests for user interactions
describe('User Interactions', () => {
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
})
it('should render document list with files', () => {
const files = createMockDocumentList(3)
renderComponent({ files })
// Documents should be visible in the list
expect(screen.getByText('Document 1')).toBeInTheDocument()
expect(screen.getByText('Document 2')).toBeInTheDocument()
expect(screen.getByText('Document 3')).toBeInTheDocument()
})
it('should call onChange when document is selected', () => {
const onChange = jest.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
// Click on a document
fireEvent.click(screen.getByText('Document 2'))
// handleChange should call onChange with the selected item
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(files[1])
})
it('should handle rapid toggle clicks', () => {
renderComponent()
const trigger = screen.getByTestId('portal-trigger')
// Rapid clicks
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle null value properties gracefully', () => {
renderComponent({
value: { id: 'doc-1', name: '', extension: '' },
})
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should handle empty files array', () => {
renderComponent({ files: [] })
// Component should render without crashing
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle very long document names', () => {
const longName = 'A'.repeat(500)
renderComponent({
value: createMockDocumentItem({ name: longName }),
})
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in document name', () => {
const specialName = '<script>alert("xss")</script>'
renderComponent({
value: createMockDocumentItem({ name: specialName }),
})
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle undefined files prop', () => {
// Test edge case where files might be undefined at runtime
const props = createDefaultProps()
// @ts-expect-error - Testing runtime edge case
props.files = undefined
render(<PreviewDocumentPicker {...props} />)
// Component should render without crashing
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle large number of files', () => {
const manyFiles = createMockDocumentList(100)
renderComponent({ files: manyFiles })
// Component should accept large files array
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle files with same name but different extensions', () => {
const files = [
createMockDocumentItem({ id: 'doc-1', name: 'document', extension: 'pdf' }),
createMockDocumentItem({ id: 'doc-2', name: 'document', extension: 'txt' }),
]
renderComponent({ files })
// Component should handle duplicate names
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
// Tests for prop variations
describe('Prop Variations', () => {
describe('value variations', () => {
it('should handle value with all fields', () => {
renderComponent({
value: {
id: 'full-doc',
name: 'Full Document',
extension: 'pdf',
},
})
expect(screen.getByText('Full Document')).toBeInTheDocument()
})
it('should handle value with minimal fields', () => {
renderComponent({
value: { id: 'minimal', name: '', extension: '' },
})
expect(screen.getByText('--')).toBeInTheDocument()
})
})
describe('files variations', () => {
it('should handle single file', () => {
renderComponent({
files: [createMockDocumentItem({ name: 'Single' })],
})
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle two files', () => {
renderComponent({
files: createMockDocumentList(2),
})
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle many files', () => {
renderComponent({
files: createMockDocumentList(50),
})
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
describe('className variations', () => {
it('should apply custom className', () => {
renderComponent({ className: 'my-custom-class' })
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument()
})
it('should work without className', () => {
renderComponent({ className: undefined })
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should handle multiple class names', () => {
renderComponent({ className: 'class-one class-two' })
const trigger = screen.getByTestId('portal-trigger')
const element = trigger.querySelector('.class-one')
expect(element).toBeInTheDocument()
expect(element).toHaveClass('class-two')
})
})
describe('extension variations', () => {
const extensions = [
{ ext: 'txt', icon: 'file-text-icon' },
{ ext: 'pdf', icon: 'file-pdf-icon' },
{ ext: 'docx', icon: 'file-word-icon' },
{ ext: 'xlsx', icon: 'file-excel-icon' },
{ ext: 'md', icon: 'file-markdown-icon' },
]
test.each(extensions)('should render correct icon for $ext extension', ({ ext, icon }) => {
renderComponent({
value: createMockDocumentItem({ extension: ext }),
files: [], // Use empty files to avoid duplicate icons
})
expect(screen.getByTestId(icon)).toBeInTheDocument()
})
})
})
// Tests for document list rendering
describe('Document List Rendering', () => {
it('should render all documents in the list', () => {
const files = createMockDocumentList(5)
renderComponent({ files })
// All documents should be visible
files.forEach((file) => {
expect(screen.getByText(file.name)).toBeInTheDocument()
})
})
it('should pass onChange handler to DocumentList', () => {
const onChange = jest.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
// Click on first document
fireEvent.click(screen.getByText('Document 1'))
expect(onChange).toHaveBeenCalledWith(files[0])
})
it('should show count header only for multiple files', () => {
// Single file - no header
const { rerender } = render(
<PreviewDocumentPicker
value={createMockDocumentItem()}
files={[createMockDocumentItem({ name: 'Single File' })]}
onChange={jest.fn()}
/>,
)
expect(screen.queryByText(/files/)).not.toBeInTheDocument()
// Multiple files - show header
rerender(
<PreviewDocumentPicker
value={createMockDocumentItem()}
files={createMockDocumentList(3)}
onChange={jest.fn()}
/>,
)
expect(screen.getByText('3 files')).toBeInTheDocument()
})
})
// Tests for visual states
describe('Visual States', () => {
it('should apply hover styles on trigger', () => {
renderComponent()
const trigger = screen.getByTestId('portal-trigger')
const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover')
expect(innerDiv).toBeInTheDocument()
})
it('should have truncate class for long names', () => {
renderComponent({
value: createMockDocumentItem({ name: 'Very Long Document Name' }),
})
const nameElement = screen.getByText('Very Long Document Name')
expect(nameElement).toHaveClass('truncate')
})
it('should have max-width on name element', () => {
renderComponent({
value: createMockDocumentItem({ name: 'Test' }),
})
const nameElement = screen.getByText('Test')
expect(nameElement).toHaveClass('max-w-[200px]')
})
})
// Tests for handleChange callback
describe('handleChange Callback', () => {
it('should call onChange with selected document item', () => {
const onChange = jest.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
// Click first document
fireEvent.click(screen.getByText('Document 1'))
expect(onChange).toHaveBeenCalledWith(files[0])
})
it('should handle different document items in files', () => {
const onChange = jest.fn()
const customFiles = [
{ id: 'custom-1', name: 'Custom File 1', extension: 'pdf' },
{ id: 'custom-2', name: 'Custom File 2', extension: 'txt' },
]
renderComponent({ files: customFiles, onChange })
// Click on first custom file
fireEvent.click(screen.getByText('Custom File 1'))
expect(onChange).toHaveBeenCalledWith(customFiles[0])
// Click on second custom file
fireEvent.click(screen.getByText('Custom File 2'))
expect(onChange).toHaveBeenCalledWith(customFiles[1])
})
it('should work with multiple sequential selections', () => {
const onChange = jest.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
// Select multiple documents sequentially
fireEvent.click(screen.getByText('Document 1'))
fireEvent.click(screen.getByText('Document 3'))
fireEvent.click(screen.getByText('Document 2'))
expect(onChange).toHaveBeenCalledTimes(3)
expect(onChange).toHaveBeenNthCalledWith(1, files[0])
expect(onChange).toHaveBeenNthCalledWith(2, files[2])
expect(onChange).toHaveBeenNthCalledWith(3, files[1])
})
})
})

View File

@ -0,0 +1,912 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import type { RetrievalConfig } from '@/types/app'
import { RETRIEVE_METHOD } from '@/types/app'
import {
DEFAULT_WEIGHTED_SCORE,
RerankingModeEnum,
WeightedScoreEnum,
} from '@/models/datasets'
import RetrievalMethodConfig from './index'
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock provider context with controllable supportRetrievalMethods
let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [
RETRIEVE_METHOD.semantic,
RETRIEVE_METHOD.fullText,
RETRIEVE_METHOD.hybrid,
]
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
supportRetrievalMethods: mockSupportRetrievalMethods,
}),
}))
// Mock model hooks with controllable return values
let mockRerankDefaultModel: { provider: { provider: string }; model: string } | undefined = {
provider: { provider: 'test-provider' },
model: 'test-rerank-model',
}
let mockIsRerankDefaultModelValid: boolean | undefined = true
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: mockRerankDefaultModel,
currentModel: mockIsRerankDefaultModelValid,
}),
}))
// Mock child component RetrievalParamConfig to simplify testing
jest.mock('../retrieval-param-config', () => ({
__esModule: true,
default: ({ type, value, onChange, showMultiModalTip }: {
type: RETRIEVE_METHOD
value: RetrievalConfig
onChange: (v: RetrievalConfig) => void
showMultiModalTip?: boolean
}) => (
<div data-testid={`retrieval-param-config-${type}`}>
<span data-testid="param-config-type">{type}</span>
<span data-testid="param-config-multimodal-tip">{String(showMultiModalTip)}</span>
<button
data-testid={`update-top-k-${type}`}
onClick={() => onChange({ ...value, top_k: 10 })}
>
Update Top K
</button>
</div>
),
}))
// Factory function to create mock RetrievalConfig
const createMockRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
})
// Helper to render component with default props
const renderComponent = (props: Partial<React.ComponentProps<typeof RetrievalMethodConfig>> = {}) => {
const defaultProps = {
value: createMockRetrievalConfig(),
onChange: jest.fn(),
}
return render(<RetrievalMethodConfig {...defaultProps} {...props} />)
}
describe('RetrievalMethodConfig', () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset mock values to defaults
mockSupportRetrievalMethods = [
RETRIEVE_METHOD.semantic,
RETRIEVE_METHOD.fullText,
RETRIEVE_METHOD.hybrid,
]
mockRerankDefaultModel = {
provider: { provider: 'test-provider' },
model: 'test-rerank-model',
}
mockIsRerankDefaultModelValid = true
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', () => {
renderComponent()
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
})
it('should render all three retrieval methods when all are supported', () => {
renderComponent()
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
})
it('should render descriptions for all retrieval methods', () => {
renderComponent()
expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.full_text_search.description')).toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.hybrid_search.description')).toBeInTheDocument()
})
it('should only render semantic search when only semantic is supported', () => {
mockSupportRetrievalMethods = [RETRIEVE_METHOD.semantic]
renderComponent()
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument()
expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument()
})
it('should only render fullText search when only fullText is supported', () => {
mockSupportRetrievalMethods = [RETRIEVE_METHOD.fullText]
renderComponent()
expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument()
})
it('should only render hybrid search when only hybrid is supported', () => {
mockSupportRetrievalMethods = [RETRIEVE_METHOD.hybrid]
renderComponent()
expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument()
expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
})
it('should render nothing when no retrieval methods are supported', () => {
mockSupportRetrievalMethods = []
const { container } = renderComponent()
// Only the wrapper div should exist
expect(container.firstChild?.childNodes.length).toBe(0)
})
it('should show RetrievalParamConfig for the active method', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
})
expect(screen.getByTestId('retrieval-param-config-semantic_search')).toBeInTheDocument()
expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument()
expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument()
})
it('should show RetrievalParamConfig for fullText when active', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }),
})
expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument()
expect(screen.getByTestId('retrieval-param-config-full_text_search')).toBeInTheDocument()
expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument()
})
it('should show RetrievalParamConfig for hybrid when active', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }),
})
expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument()
expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument()
expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument()
})
})
// Tests for props handling
describe('Props', () => {
it('should pass showMultiModalTip to RetrievalParamConfig', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
showMultiModalTip: true,
})
expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('true')
})
it('should default showMultiModalTip to false', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
})
expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('false')
})
it('should apply disabled state to option cards', () => {
renderComponent({ disabled: true })
// When disabled, clicking should not trigger onChange
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]')
expect(semanticOption).toHaveClass('cursor-not-allowed')
})
it('should default disabled to false', () => {
renderComponent()
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]')
expect(semanticOption).not.toHaveClass('cursor-not-allowed')
})
})
// Tests for user interactions and event handlers
describe('User Interactions', () => {
it('should call onChange when switching to semantic search', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }),
onChange,
})
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
}),
)
})
it('should call onChange when switching to fullText search', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
onChange,
})
const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(fullTextOption!)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
search_method: RETRIEVE_METHOD.fullText,
reranking_enable: true,
}),
)
})
it('should call onChange when switching to hybrid search', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
}),
)
})
it('should not call onChange when clicking the already active method', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
onChange,
})
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).not.toHaveBeenCalled()
})
it('should not call onChange when disabled', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
onChange,
disabled: true,
})
const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor"]')
fireEvent.click(fullTextOption!)
expect(onChange).not.toHaveBeenCalled()
})
it('should propagate onChange from RetrievalParamConfig', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
onChange,
})
const updateButton = screen.getByTestId('update-top-k-semantic_search')
fireEvent.click(updateButton)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
top_k: 10,
}),
)
})
})
// Tests for reranking model configuration
describe('Reranking Model Configuration', () => {
it('should set reranking model when switching to semantic and model is valid', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_model: {
reranking_provider_name: 'test-provider',
reranking_model_name: 'test-rerank-model',
},
reranking_enable: true,
}),
)
})
it('should preserve existing reranking model when switching', () => {
const onChange = jest.fn()
const existingModel = {
reranking_provider_name: 'existing-provider',
reranking_model_name: 'existing-model',
}
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_model: existingModel,
}),
onChange,
})
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_model: existingModel,
reranking_enable: true,
}),
)
})
it('should set reranking_enable to false when no valid model', () => {
mockIsRerankDefaultModelValid = false
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_enable: false,
}),
)
})
it('should set reranking_mode for hybrid search', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
search_method: RETRIEVE_METHOD.hybrid,
reranking_mode: RerankingModeEnum.RerankingModel,
}),
)
})
it('should set weighted score mode when no valid rerank model for hybrid', () => {
mockIsRerankDefaultModelValid = false
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_mode: RerankingModeEnum.WeightedScore,
}),
)
})
it('should set default weights for hybrid search when no existing weights', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
weights: undefined,
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: {
keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword,
},
},
}),
)
})
it('should preserve existing weights for hybrid search', () => {
const existingWeights = {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.8,
embedding_provider_name: 'test-embed-provider',
embedding_model_name: 'test-embed-model',
},
keyword_setting: {
keyword_weight: 0.2,
},
}
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
weights: existingWeights,
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
weights: existingWeights,
}),
)
})
it('should use RerankingModel mode and enable reranking for hybrid when existing reranking model', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_model: {
reranking_provider_name: 'existing-provider',
reranking_model_name: 'existing-model',
},
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_mode: RerankingModeEnum.RerankingModel,
}),
)
})
})
// Tests for callback stability and memoization
describe('Callback Stability', () => {
it('should maintain stable onSwitch callback when value changes', () => {
const onChange = jest.fn()
const value1 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 4 })
const value2 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 8 })
const { rerender } = render(
<RetrievalMethodConfig value={value1} onChange={onChange} />,
)
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledTimes(1)
rerender(<RetrievalMethodConfig value={value2} onChange={onChange} />)
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledTimes(2)
})
it('should use updated onChange callback after rerender', () => {
const onChange1 = jest.fn()
const onChange2 = jest.fn()
const value = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText })
const { rerender } = render(
<RetrievalMethodConfig value={value} onChange={onChange1} />,
)
rerender(<RetrievalMethodConfig value={value} onChange={onChange2} />)
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange1).not.toHaveBeenCalled()
expect(onChange2).toHaveBeenCalledTimes(1)
})
})
// Tests for component memoization
describe('Component Memoization', () => {
it('should be memoized with React.memo', () => {
// Verify the component is wrapped with React.memo by checking its displayName or type
expect(RetrievalMethodConfig).toBeDefined()
// React.memo components have a $$typeof property
expect((RetrievalMethodConfig as any).$$typeof).toBeDefined()
})
it('should not re-render when props are the same', () => {
const onChange = jest.fn()
const value = createMockRetrievalConfig()
const { rerender } = render(
<RetrievalMethodConfig value={value} onChange={onChange} />,
)
// Rerender with same props reference
rerender(<RetrievalMethodConfig value={value} onChange={onChange} />)
// Component should still be rendered correctly
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
})
})
// Tests for edge cases and error handling
describe('Edge Cases', () => {
it('should handle undefined reranking_model', () => {
const onChange = jest.fn()
const value = createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
})
// @ts-expect-error - Testing edge case
value.reranking_model = undefined
renderComponent({
value,
onChange,
})
// Should not crash
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
})
it('should handle missing default model', () => {
mockRerankDefaultModel = undefined
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
)
})
it('should use fallback empty string when default model provider is undefined', () => {
// @ts-expect-error - Testing edge case where provider is undefined
mockRerankDefaultModel = { provider: undefined, model: 'test-model' }
mockIsRerankDefaultModelValid = true
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_model: {
reranking_provider_name: '',
reranking_model_name: 'test-model',
},
}),
)
})
it('should use fallback empty string when default model name is undefined', () => {
// @ts-expect-error - Testing edge case where model is undefined
mockRerankDefaultModel = { provider: { provider: 'test-provider' }, model: undefined }
mockIsRerankDefaultModelValid = true
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}),
onChange,
})
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_model: {
reranking_provider_name: 'test-provider',
reranking_model_name: '',
},
}),
)
})
it('should handle rapid sequential clicks', () => {
const onChange = jest.fn()
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
onChange,
})
const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]')
// Rapid clicks
fireEvent.click(fullTextOption!)
fireEvent.click(hybridOption!)
fireEvent.click(fullTextOption!)
expect(onChange).toHaveBeenCalledTimes(3)
})
it('should handle empty supportRetrievalMethods array', () => {
mockSupportRetrievalMethods = []
const { container } = renderComponent()
expect(container.querySelector('[class*="flex-col"]')?.childNodes.length).toBe(0)
})
it('should handle partial supportRetrievalMethods', () => {
mockSupportRetrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.hybrid]
renderComponent()
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
})
it('should handle value with all optional fields set', () => {
const fullValue = createMockRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'model',
},
top_k: 10,
score_threshold_enabled: true,
score_threshold: 0.8,
reranking_mode: RerankingModeEnum.WeightedScore,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.6,
embedding_provider_name: 'embed-provider',
embedding_model_name: 'embed-model',
},
keyword_setting: {
keyword_weight: 0.4,
},
},
})
renderComponent({ value: fullValue })
expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument()
})
})
// Tests for all prop variations
describe('Prop Variations', () => {
it('should render with minimum required props', () => {
const { container } = render(
<RetrievalMethodConfig
value={createMockRetrievalConfig()}
onChange={jest.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with all props set', () => {
renderComponent({
disabled: true,
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }),
showMultiModalTip: true,
onChange: jest.fn(),
})
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
})
describe('disabled prop variations', () => {
it('should handle disabled=true', () => {
renderComponent({ disabled: true })
const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]')
expect(option).toHaveClass('cursor-not-allowed')
})
it('should handle disabled=false', () => {
renderComponent({ disabled: false })
const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]')
expect(option).toHaveClass('cursor-pointer')
})
})
describe('search_method variations', () => {
const methods = [
RETRIEVE_METHOD.semantic,
RETRIEVE_METHOD.fullText,
RETRIEVE_METHOD.hybrid,
]
test.each(methods)('should correctly highlight %s when active', (method) => {
renderComponent({
value: createMockRetrievalConfig({ search_method: method }),
})
// The active method should have its RetrievalParamConfig rendered
expect(screen.getByTestId(`retrieval-param-config-${method}`)).toBeInTheDocument()
})
})
describe('showMultiModalTip variations', () => {
it('should pass true to child component', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
showMultiModalTip: true,
})
expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('true')
})
it('should pass false to child component', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
showMultiModalTip: false,
})
expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('false')
})
})
})
// Tests for active state visual indication
describe('Active State Visual Indication', () => {
it('should show recommended badge only on hybrid search', () => {
renderComponent()
// The hybrid search option should have the recommended badge
// This is verified by checking the isRecommended prop passed to OptionCard
const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title')
const hybridCard = hybridTitle.closest('div[class*="cursor"]')
// Should contain recommended badge from OptionCard
expect(hybridCard?.querySelector('[class*="badge"]') || screen.queryByText('datasetCreation.stepTwo.recommend')).toBeTruthy()
})
})
// Tests for integration with OptionCard
describe('OptionCard Integration', () => {
it('should pass correct props to OptionCard for semantic search', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }),
})
const semanticTitle = screen.getByText('dataset.retrieval.semantic_search.title')
expect(semanticTitle).toBeInTheDocument()
// Check description
const semanticDesc = screen.getByText('dataset.retrieval.semantic_search.description')
expect(semanticDesc).toBeInTheDocument()
})
it('should pass correct props to OptionCard for fullText search', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }),
})
const fullTextTitle = screen.getByText('dataset.retrieval.full_text_search.title')
expect(fullTextTitle).toBeInTheDocument()
const fullTextDesc = screen.getByText('dataset.retrieval.full_text_search.description')
expect(fullTextDesc).toBeInTheDocument()
})
it('should pass correct props to OptionCard for hybrid search', () => {
renderComponent({
value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }),
})
const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title')
expect(hybridTitle).toBeInTheDocument()
const hybridDesc = screen.getByText('dataset.retrieval.hybrid_search.description')
expect(hybridDesc).toBeInTheDocument()
})
})
})

View File

@ -16,13 +16,6 @@ jest.mock('next/navigation', () => ({
}),
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,

View File

@ -15,13 +15,6 @@ jest.mock('next/navigation', () => ({
}),
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,

View File

@ -4,12 +4,6 @@ import AppCard, { type AppCardProps } from './index'
import type { App } from '@/models/explore'
import { AppModeEnum } from '@/types/app'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/app-icon', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="app-icon">{children}</div>,

View File

@ -2,12 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import NoData from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('NoData', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@ -2,12 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import ResDownload from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockType = { Link: 'mock-link' }
let capturedProps: Record<string, unknown> | undefined

View File

@ -3,13 +3,6 @@ import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfirmModal from './index'
// Mock external dependencies as per guidelines
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Test utilities
const defaultProps = {
show: true,

View File

@ -326,12 +326,19 @@ describe('ComponentName', () => {
### General
1. **i18n**: Always return key
1. **i18n**: Uses shared mock at `web/__mocks__/react-i18next.ts` (auto-loaded by Jest)
The shared mock returns translation keys as-is. For custom translations, override:
```typescript
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string) => {
const translations: Record<string, string> = {
'my.custom.key': 'Custom translation',
}
return translations[key] || key
},
}),
}))
```