mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat-agent-mask
This commit is contained in:
commit
97a5c3f3f1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 || ''}`,
|
||||
|
|
|
|||
|
|
@ -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 || ''}`,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}),
|
||||
}))
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue