mirror of
https://github.com/langgenius/dify.git
synced 2026-01-21 04:25:23 +08:00
Compare commits
5 Commits
feat/docum
...
feat/e2e-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 47724ec764 | |||
| 3863894072 | |||
| cf20e9fd38 | |||
| a8a0f2c900 | |||
| 7b968c6c2e |
@ -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 uses shared mock (auto-loaded); only override locally for custom translations
|
||||
- [ ] i18n mock returns keys (not empty strings)
|
||||
- [ ] Router mocks match actual Next.js API
|
||||
- [ ] Mocks reflect actual component conditional behavior
|
||||
- [ ] Only mock: API services, complex context providers, third-party libs
|
||||
|
||||
@ -318,4 +318,3 @@ 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,22 +46,12 @@ Only mock these categories:
|
||||
|
||||
## Essential Mocks
|
||||
|
||||
### 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:
|
||||
### 1. i18n (Always Required)
|
||||
|
||||
```typescript
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'my.custom.key': 'Custom translation',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
```
|
||||
@ -323,7 +313,7 @@ Need to use a component in test?
|
||||
│ └─ YES → Mock it (next/navigation, external SDKs)
|
||||
│
|
||||
└─ Is it i18n?
|
||||
└─ YES → Uses shared mock (auto-loaded). Override only for custom translations
|
||||
└─ YES → Mock to return keys
|
||||
```
|
||||
|
||||
## Factory Function Pattern
|
||||
|
||||
@ -626,7 +626,17 @@ QUEUE_MONITOR_ALERT_EMAILS=
|
||||
QUEUE_MONITOR_INTERVAL=30
|
||||
|
||||
# Swagger UI configuration
|
||||
SWAGGER_UI_ENABLED=true
|
||||
# SECURITY: Swagger UI is automatically disabled in PRODUCTION environment (DEPLOY_ENV=PRODUCTION)
|
||||
# to prevent API information disclosure.
|
||||
#
|
||||
# Behavior:
|
||||
# - DEPLOY_ENV=PRODUCTION + SWAGGER_UI_ENABLED not set -> Swagger DISABLED (secure default)
|
||||
# - DEPLOY_ENV=DEVELOPMENT/TESTING + SWAGGER_UI_ENABLED not set -> Swagger ENABLED
|
||||
# - SWAGGER_UI_ENABLED=true -> Swagger ENABLED (overrides environment check)
|
||||
# - SWAGGER_UI_ENABLED=false -> Swagger DISABLED (explicit disable)
|
||||
#
|
||||
# For development, you can uncomment below or set DEPLOY_ENV=DEVELOPMENT
|
||||
# SWAGGER_UI_ENABLED=false
|
||||
SWAGGER_UI_PATH=/swagger-ui.html
|
||||
|
||||
# Whether to encrypt dataset IDs when exporting DSL files (default: true)
|
||||
|
||||
@ -1252,9 +1252,19 @@ class WorkflowLogConfig(BaseSettings):
|
||||
|
||||
|
||||
class SwaggerUIConfig(BaseSettings):
|
||||
SWAGGER_UI_ENABLED: bool = Field(
|
||||
description="Whether to enable Swagger UI in api module",
|
||||
default=True,
|
||||
"""
|
||||
Configuration for Swagger UI documentation.
|
||||
|
||||
Security Note: Swagger UI is automatically disabled in PRODUCTION environment
|
||||
to prevent API information disclosure. Set SWAGGER_UI_ENABLED=true explicitly
|
||||
to enable in production if needed.
|
||||
"""
|
||||
|
||||
SWAGGER_UI_ENABLED: bool | None = Field(
|
||||
description="Whether to enable Swagger UI in api module. "
|
||||
"Automatically disabled in PRODUCTION environment for security. "
|
||||
"Set to true explicitly to enable in production.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
SWAGGER_UI_PATH: str = Field(
|
||||
@ -1262,6 +1272,23 @@ class SwaggerUIConfig(BaseSettings):
|
||||
default="/swagger-ui.html",
|
||||
)
|
||||
|
||||
@property
|
||||
def swagger_ui_enabled(self) -> bool:
|
||||
"""
|
||||
Compute whether Swagger UI should be enabled.
|
||||
|
||||
If SWAGGER_UI_ENABLED is explicitly set, use that value.
|
||||
Otherwise, disable in PRODUCTION environment for security.
|
||||
"""
|
||||
if self.SWAGGER_UI_ENABLED is not None:
|
||||
return self.SWAGGER_UI_ENABLED
|
||||
|
||||
# Auto-disable in production environment
|
||||
import os
|
||||
|
||||
deploy_env = os.environ.get("DEPLOY_ENV", "PRODUCTION")
|
||||
return deploy_env.upper() != "PRODUCTION"
|
||||
|
||||
|
||||
class TenantIsolatedTaskQueueConfig(BaseSettings):
|
||||
TENANT_ISOLATED_TASK_CONCURRENCY: int = Field(
|
||||
|
||||
@ -107,7 +107,7 @@ class KeywordStoreConfig(BaseSettings):
|
||||
|
||||
class DatabaseConfig(BaseSettings):
|
||||
# Database type selector
|
||||
DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field(
|
||||
DB_TYPE: Literal["postgresql", "mysql", "oceanbase"] = Field(
|
||||
description="Database type to use. OceanBase is MySQL-compatible.",
|
||||
default="postgresql",
|
||||
)
|
||||
|
||||
@ -223,7 +223,6 @@ 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 | VectorType.SEEKDB:
|
||||
case VectorType.OCEANBASE:
|
||||
from core.rag.datasource.vdb.oceanbase.oceanbase_vector import OceanBaseVectorFactory
|
||||
|
||||
return OceanBaseVectorFactory
|
||||
|
||||
@ -27,7 +27,6 @@ class VectorType(StrEnum):
|
||||
UPSTASH = "upstash"
|
||||
TIDB_ON_QDRANT = "tidb_on_qdrant"
|
||||
OCEANBASE = "oceanbase"
|
||||
SEEKDB = "seekdb"
|
||||
OPENGAUSS = "opengauss"
|
||||
TABLESTORE = "tablestore"
|
||||
HUAWEI_CLOUD = "huawei_cloud"
|
||||
|
||||
@ -140,10 +140,6 @@ 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
|
||||
@ -162,7 +158,6 @@ 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,
|
||||
@ -201,6 +196,10 @@ 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,11 +8,9 @@ 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,7 +9,6 @@ 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
|
||||
|
||||
|
||||
@ -84,29 +83,3 @@ 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
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
"""
|
||||
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))
|
||||
@ -1,169 +0,0 @@
|
||||
"""
|
||||
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,7 +9,6 @@ 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
|
||||
@ -18,7 +17,6 @@ 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
|
||||
@ -41,7 +39,6 @@ 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,
|
||||
@ -53,7 +50,6 @@ 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
|
||||
@ -67,7 +63,6 @@ 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."""
|
||||
@ -127,51 +122,20 @@ class Worker(threading.Thread):
|
||||
Args:
|
||||
node: The node instance to execute
|
||||
"""
|
||||
node.ensure_execution_id()
|
||||
|
||||
error: Exception | None = None
|
||||
|
||||
# Execute the node with preserved context if Flask app is provided
|
||||
if self._flask_app and self._context_vars:
|
||||
with preserve_flask_contexts(
|
||||
flask_app=self._flask_app,
|
||||
context_vars=self._context_vars,
|
||||
):
|
||||
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:
|
||||
# Execute the node
|
||||
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
|
||||
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)
|
||||
|
||||
@ -14,7 +14,6 @@ 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
|
||||
|
||||
@ -40,7 +39,6 @@ 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,
|
||||
@ -55,7 +53,6 @@ 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
|
||||
@ -68,7 +65,6 @@ 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
|
||||
@ -148,7 +144,6 @@ 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,15 +244,6 @@ 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))
|
||||
|
||||
@ -265,12 +256,14 @@ class Node(Generic[NodeDataT]):
|
||||
raise NotImplementedError
|
||||
|
||||
def run(self) -> Generator[GraphNodeEventBase, None, None]:
|
||||
execution_id = self.ensure_execution_id()
|
||||
# Generate a single node execution ID to use for all events
|
||||
if not self._node_execution_id:
|
||||
self._node_execution_id = str(uuid4())
|
||||
self._start_at = naive_utc_now()
|
||||
|
||||
# Create and push start event with required fields
|
||||
start_event = NodeRunStartedEvent(
|
||||
id=execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.title,
|
||||
@ -328,7 +321,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.execution_id
|
||||
event.id = self._node_execution_id
|
||||
yield event
|
||||
else:
|
||||
yield event
|
||||
@ -340,7 +333,7 @@ class Node(Generic[NodeDataT]):
|
||||
error_type="WorkflowNodeError",
|
||||
)
|
||||
yield NodeRunFailedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
start_at=self._start_at,
|
||||
@ -519,7 +512,7 @@ class Node(Generic[NodeDataT]):
|
||||
match result.status:
|
||||
case WorkflowNodeExecutionStatus.FAILED:
|
||||
return NodeRunFailedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self.id,
|
||||
node_type=self.node_type,
|
||||
start_at=self._start_at,
|
||||
@ -528,7 +521,7 @@ class Node(Generic[NodeDataT]):
|
||||
)
|
||||
case WorkflowNodeExecutionStatus.SUCCEEDED:
|
||||
return NodeRunSucceededEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self.id,
|
||||
node_type=self.node_type,
|
||||
start_at=self._start_at,
|
||||
@ -544,7 +537,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: StreamChunkEvent) -> NodeRunStreamChunkEvent:
|
||||
return NodeRunStreamChunkEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
selector=event.selector,
|
||||
@ -557,7 +550,7 @@ class Node(Generic[NodeDataT]):
|
||||
match event.node_run_result.status:
|
||||
case WorkflowNodeExecutionStatus.SUCCEEDED:
|
||||
return NodeRunSucceededEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
start_at=self._start_at,
|
||||
@ -565,7 +558,7 @@ class Node(Generic[NodeDataT]):
|
||||
)
|
||||
case WorkflowNodeExecutionStatus.FAILED:
|
||||
return NodeRunFailedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
start_at=self._start_at,
|
||||
@ -580,7 +573,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: PauseRequestedEvent) -> NodeRunPauseRequestedEvent:
|
||||
return NodeRunPauseRequestedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.PAUSED),
|
||||
@ -590,7 +583,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: AgentLogEvent) -> NodeRunAgentLogEvent:
|
||||
return NodeRunAgentLogEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
message_id=event.message_id,
|
||||
@ -606,7 +599,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: LoopStartedEvent) -> NodeRunLoopStartedEvent:
|
||||
return NodeRunLoopStartedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -619,7 +612,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: LoopNextEvent) -> NodeRunLoopNextEvent:
|
||||
return NodeRunLoopNextEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -630,7 +623,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: LoopSucceededEvent) -> NodeRunLoopSucceededEvent:
|
||||
return NodeRunLoopSucceededEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -644,7 +637,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: LoopFailedEvent) -> NodeRunLoopFailedEvent:
|
||||
return NodeRunLoopFailedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -659,7 +652,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: IterationStartedEvent) -> NodeRunIterationStartedEvent:
|
||||
return NodeRunIterationStartedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -672,7 +665,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: IterationNextEvent) -> NodeRunIterationNextEvent:
|
||||
return NodeRunIterationNextEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -683,7 +676,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: IterationSucceededEvent) -> NodeRunIterationSucceededEvent:
|
||||
return NodeRunIterationSucceededEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -697,7 +690,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: IterationFailedEvent) -> NodeRunIterationFailedEvent:
|
||||
return NodeRunIterationFailedEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_execution_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self.node_type,
|
||||
node_title=self.node_data.title,
|
||||
@ -712,7 +705,7 @@ class Node(Generic[NodeDataT]):
|
||||
@_dispatch.register
|
||||
def _(self, event: RunRetrieverResourceEvent) -> NodeRunRetrieverResourceEvent:
|
||||
return NodeRunRetrieverResourceEvent(
|
||||
id=self.execution_id,
|
||||
id=self._node_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, ObservabilityLayer
|
||||
from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer
|
||||
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,7 +23,6 @@ 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
|
||||
@ -99,10 +98,6 @@ 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
|
||||
|
||||
|
||||
@ -22,8 +22,8 @@ login_manager = flask_login.LoginManager()
|
||||
@login_manager.request_loader
|
||||
def load_user_from_request(request_from_flask_login):
|
||||
"""Load user based on the request."""
|
||||
# Skip authentication for documentation endpoints
|
||||
if dify_config.SWAGGER_UI_ENABLED and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")):
|
||||
# Skip authentication for documentation endpoints (only when Swagger is enabled)
|
||||
if dify_config.swagger_ui_enabled and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")):
|
||||
return None
|
||||
|
||||
auth_token = extract_access_token(request)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import functools
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
@ -6,13 +7,22 @@ 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:
|
||||
@ -33,7 +43,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,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Union
|
||||
|
||||
@ -72,13 +71,3 @@ 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"
|
||||
|
||||
@ -131,12 +131,28 @@ class ExternalApi(Api):
|
||||
}
|
||||
|
||||
def __init__(self, app: Blueprint | Flask, *args, **kwargs):
|
||||
import logging
|
||||
import os
|
||||
|
||||
kwargs.setdefault("authorizations", self._authorizations)
|
||||
kwargs.setdefault("security", "Bearer")
|
||||
kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
|
||||
kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False
|
||||
|
||||
# Security: Use computed swagger_ui_enabled which respects DEPLOY_ENV
|
||||
swagger_enabled = dify_config.swagger_ui_enabled
|
||||
kwargs["add_specs"] = swagger_enabled
|
||||
kwargs["doc"] = dify_config.SWAGGER_UI_PATH if swagger_enabled else False
|
||||
|
||||
# manual separate call on construction and init_app to ensure configs in kwargs effective
|
||||
super().__init__(app=None, *args, **kwargs)
|
||||
self.init_app(app, **kwargs)
|
||||
register_external_error_handlers(self)
|
||||
|
||||
# Security: Log warning when Swagger is enabled in production environment
|
||||
deploy_env = os.environ.get("DEPLOY_ENV", "PRODUCTION")
|
||||
if swagger_enabled and deploy_env.upper() == "PRODUCTION":
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
"SECURITY WARNING: Swagger UI is ENABLED in PRODUCTION environment. "
|
||||
"This may expose sensitive API documentation. "
|
||||
"Set SWAGGER_UI_ENABLED=false or remove the explicit setting to disable."
|
||||
)
|
||||
|
||||
@ -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 in ["mysql", "oceanbase", "seekdb"]:
|
||||
elif dify_config.DB_TYPE == "mysql":
|
||||
return f"DATE(CONVERT_TZ({field}, 'UTC', {target_timezone}))"
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported database type: {dify_config.DB_TYPE}")
|
||||
|
||||
@ -33,11 +33,6 @@ 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__)
|
||||
|
||||
|
||||
@ -322,8 +317,7 @@ class WebhookService:
|
||||
try:
|
||||
file_content = request.get_data()
|
||||
if file_content:
|
||||
mimetype = cls._detect_binary_mimetype(file_content)
|
||||
file_obj = cls._create_file_from_binary(file_content, mimetype, webhook_trigger)
|
||||
file_obj = cls._create_file_from_binary(file_content, "application/octet-stream", webhook_trigger)
|
||||
return {"raw": file_obj.to_dict()}, {}
|
||||
else:
|
||||
return {"raw": None}, {}
|
||||
@ -347,18 +341,6 @@ 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
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@ -1,219 +0,0 @@
|
||||
"""
|
||||
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,70 +110,6 @@ 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__)
|
||||
|
||||
7
web/.gitignore
vendored
7
web/.gitignore
vendored
@ -8,6 +8,13 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e
|
||||
/e2e/.auth/
|
||||
/e2e/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
@ -2,4 +2,3 @@
|
||||
|
||||
- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests.
|
||||
- When proposing or saving tests, re-read that document and follow every requirement.
|
||||
- All frontend tests MUST also comply with the `frontend-testing` skill. Treat the skill as a mandatory constraint, not optional guidance.
|
||||
|
||||
@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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,6 +4,12 @@ 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,6 +4,12 @@ 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,6 +3,13 @@ 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,6 +3,12 @@ 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,6 +1,12 @@
|
||||
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,6 +2,12 @@ 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,6 +3,12 @@ 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,6 +2,12 @@ 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,6 +6,12 @@ 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,6 +5,12 @@ 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,6 +1,12 @@
|
||||
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,6 +51,12 @@ const mockFiles: FileEntity[] = [
|
||||
},
|
||||
]
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/debug-configuration', () => ({
|
||||
__esModule: true,
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
|
||||
@ -1,287 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -401,6 +401,7 @@ function AppCard({
|
||||
/>
|
||||
<CustomizeModal
|
||||
isShow={showCustomizeModal}
|
||||
linkUrl=""
|
||||
onClose={() => setShowCustomizeModal(false)}
|
||||
appId={appInfo.id}
|
||||
api_base_url={appInfo.api_base_url}
|
||||
|
||||
@ -1,434 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import CustomizeModal from './index'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Mock useDocLink from context
|
||||
const mockDocLink = jest.fn((path?: string) => `https://docs.dify.ai/en-US${path || ''}`)
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => mockDocLink,
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn()
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
describe('CustomizeModal', () => {
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
onClose: jest.fn(),
|
||||
api_base_url: 'https://api.example.com',
|
||||
appId: 'test-app-id-123',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests - verify component renders correctly with various configurations
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when isShow is true', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render content when isShow is false', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, isShow: false }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appOverview.overview.appInfo.customize.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render modal description', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.explanation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render way 1 and way 2 tags', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.way 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.way 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render all step numbers (1, 2, 3)', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render step instructions', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step2')).toBeInTheDocument()
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render environment variables with appId and api_base_url', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
|
||||
expect(preElement).toBeInTheDocument()
|
||||
expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'test-app-id-123\'')
|
||||
expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'https://api.example.com\'')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render GitHub icon in step 1 button', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert - find the GitHub link and verify it contains an SVG icon
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toBeInTheDocument()
|
||||
expect(githubLink.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests - verify props are correctly applied
|
||||
describe('Props', () => {
|
||||
it('should display correct appId in environment variables', async () => {
|
||||
// Arrange
|
||||
const customAppId = 'custom-app-id-456'
|
||||
const props = { ...defaultProps, appId: customAppId }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
|
||||
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${customAppId}'`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should display correct api_base_url in environment variables', async () => {
|
||||
// Arrange
|
||||
const customApiUrl = 'https://custom-api.example.com'
|
||||
const props = { ...defaultProps, api_base_url: customApiUrl }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
|
||||
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${customApiUrl}'`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Mode-based conditional rendering tests - verify GitHub link changes based on app mode
|
||||
describe('Mode-based GitHub link', () => {
|
||||
it('should link to webapp-conversation repo for CHAT mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, mode: AppModeEnum.CHAT }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to webapp-conversation repo for ADVANCED_CHAT mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, mode: AppModeEnum.ADVANCED_CHAT }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to webapp-text-generator repo for COMPLETION mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, mode: AppModeEnum.COMPLETION }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to webapp-text-generator repo for WORKFLOW mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, mode: AppModeEnum.WORKFLOW }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to webapp-text-generator repo for AGENT_CHAT mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, mode: AppModeEnum.AGENT_CHAT }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// External links tests - verify external links have correct security attributes
|
||||
describe('External links', () => {
|
||||
it('should have GitHub repo link that opens in new tab', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
expect(githubLink).toHaveAttribute('target', '_blank')
|
||||
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have Vercel docs link that opens in new tab', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const vercelLink = screen.getByRole('link', { name: /step2Operation/i })
|
||||
expect(vercelLink).toHaveAttribute('href', 'https://vercel.com/docs/concepts/deployments/git/vercel-for-github')
|
||||
expect(vercelLink).toHaveAttribute('target', '_blank')
|
||||
expect(vercelLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions tests - verify user actions trigger expected behaviors
|
||||
describe('User Interactions', () => {
|
||||
it('should call window.open with doc link when way 2 button is clicked', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.way2.operation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const way2Button = screen.getByText('appOverview.overview.appInfo.customize.way2.operation').closest('button')
|
||||
expect(way2Button).toBeInTheDocument()
|
||||
fireEvent.click(way2Button!)
|
||||
|
||||
// Assert
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/guides/application-publishing/developing-with-apis'),
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onClose when modal close button is clicked', async () => {
|
||||
// Arrange
|
||||
const onClose = jest.fn()
|
||||
const props = { ...defaultProps, onClose }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Wait for modal to be fully rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the close button by navigating from the heading to the close icon
|
||||
// The close icon is an SVG inside a sibling div of the title
|
||||
const heading = screen.getByRole('heading', { name: /customize\.title/i })
|
||||
const closeIcon = heading.parentElement!.querySelector('svg')
|
||||
|
||||
// Assert - closeIcon must exist for the test to be valid
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
fireEvent.click(closeIcon!)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases tests - verify component handles boundary conditions
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty appId', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, appId: '' }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
|
||||
expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'\'')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty api_base_url', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, api_base_url: '' }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
|
||||
expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'\'')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in appId', async () => {
|
||||
// Arrange
|
||||
const specialAppId = 'app-id-with-special-chars_123'
|
||||
const props = { ...defaultProps, appId: specialAppId }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
|
||||
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${specialAppId}'`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle URL with special characters in api_base_url', async () => {
|
||||
// Arrange
|
||||
const specialApiUrl = 'https://api.example.com:8080/v1'
|
||||
const props = { ...defaultProps, api_base_url: specialApiUrl }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
|
||||
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${specialApiUrl}'`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// StepNum component tests - verify step number styling
|
||||
describe('StepNum component', () => {
|
||||
it('should render step numbers with correct styling class', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert - The StepNum component is the direct container of the text
|
||||
await waitFor(() => {
|
||||
const stepNumber1 = screen.getByText('1')
|
||||
expect(stepNumber1).toHaveClass('rounded-2xl')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// GithubIcon component tests - verify GitHub icon renders correctly
|
||||
describe('GithubIcon component', () => {
|
||||
it('should render GitHub icon SVG within GitHub link button', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<CustomizeModal {...props} />)
|
||||
|
||||
// Assert - Find GitHub link and verify it contains an SVG icon with expected class
|
||||
await waitFor(() => {
|
||||
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
|
||||
const githubIcon = githubLink.querySelector('svg')
|
||||
expect(githubIcon).toBeInTheDocument()
|
||||
expect(githubIcon).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -12,6 +12,7 @@ import Tag from '@/app/components/base/tag'
|
||||
type IShareLinkProps = {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
linkUrl: string
|
||||
api_base_url: string
|
||||
appId: string
|
||||
mode: AppModeEnum
|
||||
|
||||
@ -18,6 +18,12 @@ 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,6 +16,12 @@ 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,6 +49,13 @@ 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,6 +22,12 @@ 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,6 +15,12 @@ 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
@ -1,53 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,94 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,363 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,493 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,106 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,573 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,287 +0,0 @@
|
||||
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,6 +6,13 @@ 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,6 +1,12 @@
|
||||
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,6 +1,12 @@
|
||||
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,6 +1,12 @@
|
||||
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,6 +1,12 @@
|
||||
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,6 +5,12 @@ 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
|
||||
|
||||
@ -1,500 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CustomPage from './index'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { contactSalesUrl } from '@/app/components/billing/config'
|
||||
|
||||
// Mock external dependencies only
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContext: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock the complex CustomWebAppBrand component to avoid dependency issues
|
||||
// This is acceptable because it has complex dependencies (fetch, APIs)
|
||||
jest.mock('../custom-web-app-brand', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
|
||||
}))
|
||||
|
||||
// Get the mocked functions
|
||||
const { useProviderContext } = jest.requireMock('@/context/provider-context')
|
||||
const { useModalContext } = jest.requireMock('@/context/modal-context')
|
||||
|
||||
describe('CustomPage', () => {
|
||||
const mockSetShowPricingModal = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Default mock setup
|
||||
useModalContext.mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
})
|
||||
})
|
||||
|
||||
// Helper function to render with different provider contexts
|
||||
const renderWithContext = (overrides = {}) => {
|
||||
useProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue(overrides),
|
||||
)
|
||||
return render(<CustomPage />)
|
||||
}
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should always render CustomWebAppBrand component', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct layout structure', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext()
|
||||
|
||||
// Assert
|
||||
const mainContainer = container.querySelector('.flex.flex-col')
|
||||
expect(mainContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional Rendering - Billing Tip
|
||||
describe('Billing Tip Banner', () => {
|
||||
it('should show billing tip when enableBilling is true and plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when enableBilling is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when plan is professional', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when plan is team', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct gradient styling for billing tip banner', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const banner = container.querySelector('.bg-gradient-to-r')
|
||||
expect(banner).toBeInTheDocument()
|
||||
expect(banner).toHaveClass('from-components-input-border-active-prompt-1')
|
||||
expect(banner).toHaveClass('to-components-input-border-active-prompt-2')
|
||||
expect(banner).toHaveClass('p-4')
|
||||
expect(banner).toHaveClass('pl-6')
|
||||
expect(banner).toHaveClass('shadow-lg')
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional Rendering - Contact Sales
|
||||
describe('Contact Sales Section', () => {
|
||||
it('should show contact section when enableBilling is true and plan is professional', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert - Check that contact section exists with all parts
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.prefix')
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.suffix')
|
||||
})
|
||||
|
||||
it('should show contact section when enableBilling is true and plan is team', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert - Check that contact section exists with all parts
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.prefix')
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.suffix')
|
||||
})
|
||||
|
||||
it('should not show contact section when enableBilling is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show contact section when plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render contact link with correct URL', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(link).toHaveAttribute('href', contactSalesUrl)
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should have correct positioning for contact section', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveClass('h-[50px]')
|
||||
expect(contactSection).toHaveClass('text-xs')
|
||||
expect(contactSection).toHaveClass('leading-[50px]')
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call setShowPricingModal when upgrade button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal without arguments', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should handle multiple clicks on upgrade button', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
await user.click(upgradeButton)
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should have correct button styling for upgrade button', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
expect(upgradeButton).toHaveClass('cursor-pointer')
|
||||
expect(upgradeButton).toHaveClass('bg-white')
|
||||
expect(upgradeButton).toHaveClass('text-text-accent')
|
||||
expect(upgradeButton).toHaveClass('rounded-3xl')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined plan type gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: undefined },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plan without type property', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: null },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show any banners when both conditions are false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle enableBilling undefined', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: undefined,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only billing tip for sandbox plan, not contact section', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only contact section for professional plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only contact section for team plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty plan object', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: {},
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should have clickable upgrade button', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
expect(upgradeButton).toBeInTheDocument()
|
||||
expect(upgradeButton).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should have proper external link attributes on contact link', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should have proper text hierarchy in billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const title = screen.getByText('custom.upgradeTip.title')
|
||||
const description = screen.getByText('custom.upgradeTip.des')
|
||||
|
||||
expect(title).toHaveClass('title-xl-semi-bold')
|
||||
expect(description).toHaveClass('system-sm-regular')
|
||||
})
|
||||
|
||||
it('should use semantic color classes', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert - Check that the billing tip has text content (which implies semantic colors)
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should render both CustomWebAppBrand and billing tip together', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both CustomWebAppBrand and contact section together', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only CustomWebAppBrand when no billing conditions met', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,641 +0,0 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { DocumentItem } from '@/models/datasets'
|
||||
import PreviewDocumentPicker from './preview-document-picker'
|
||||
|
||||
// Override shared i18n mock for custom translations
|
||||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,905 +0,0 @@
|
||||
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 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,564 +0,0 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Tab from './index'
|
||||
|
||||
// Define enum locally to avoid importing the whole module
|
||||
enum CreateFromDSLModalTab {
|
||||
FROM_FILE = 'from-file',
|
||||
FROM_URL = 'from-url',
|
||||
}
|
||||
|
||||
// Mock the create-from-dsl-modal module to export the enum
|
||||
jest.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_FILE: 'from-file',
|
||||
FROM_URL: 'from-url',
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Tab', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for basic rendering
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two tab items', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should have 2 clickable tab items
|
||||
const tabItems = container.querySelectorAll('.cursor-pointer')
|
||||
expect(tabItems.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render with correct container styling', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tabContainer = container.firstChild as HTMLElement
|
||||
expect(tabContainer).toHaveClass('flex')
|
||||
expect(tabContainer).toHaveClass('h-9')
|
||||
expect(tabContainer).toHaveClass('items-center')
|
||||
expect(tabContainer).toHaveClass('gap-x-6')
|
||||
expect(tabContainer).toHaveClass('border-b')
|
||||
expect(tabContainer).toHaveClass('border-divider-subtle')
|
||||
expect(tabContainer).toHaveClass('px-6')
|
||||
})
|
||||
|
||||
it('should render tab labels with translation keys', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for active tab indication
|
||||
describe('Active Tab Indication', () => {
|
||||
it('should show FROM_FILE tab as active when currentTab is FROM_FILE', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// getByText returns the Item element directly (text is inside it)
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
// Active tab should have text-text-primary class
|
||||
expect(fileTab).toHaveClass('text-text-primary')
|
||||
// Inactive tab should have text-text-tertiary class
|
||||
expect(urlTab).toHaveClass('text-text-tertiary')
|
||||
expect(urlTab).not.toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should show FROM_URL tab as active when currentTab is FROM_URL', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_URL}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
// Inactive tab should have text-text-tertiary class
|
||||
expect(fileTab).toHaveClass('text-text-tertiary')
|
||||
expect(fileTab).not.toHaveClass('text-text-primary')
|
||||
// Active tab should have text-text-primary class
|
||||
expect(urlTab).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should render active indicator bar for active tab', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Active tab should have the indicator bar
|
||||
const indicatorBars = container.querySelectorAll('.bg-util-colors-blue-brand-blue-brand-600')
|
||||
expect(indicatorBars.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should render active indicator bar for URL tab when active', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_URL}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should have one indicator bar
|
||||
const indicatorBars = container.querySelectorAll('.bg-util-colors-blue-brand-blue-brand-600')
|
||||
expect(indicatorBars.length).toBe(1)
|
||||
|
||||
// The indicator should be in the URL tab
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
expect(urlTab.querySelector('.bg-util-colors-blue-brand-blue-brand-600')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render indicator bar for inactive tab', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The URL tab (inactive) should not have an indicator bar
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
expect(urlTab.querySelector('.bg-util-colors-blue-brand-blue-brand-600')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for user interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call setCurrentTab with FROM_FILE when file tab is clicked', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_URL}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
fireEvent.click(fileTab)
|
||||
|
||||
expect(setCurrentTab).toHaveBeenCalledTimes(1)
|
||||
// .bind() passes tab.key as first arg, event as second
|
||||
expect(setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_FILE, expect.anything())
|
||||
})
|
||||
|
||||
it('should call setCurrentTab with FROM_URL when url tab is clicked', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
fireEvent.click(urlTab)
|
||||
|
||||
expect(setCurrentTab).toHaveBeenCalledTimes(1)
|
||||
expect(setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL, expect.anything())
|
||||
})
|
||||
|
||||
it('should call setCurrentTab when clicking already active tab', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
fireEvent.click(fileTab)
|
||||
|
||||
// Should still call setCurrentTab even for active tab
|
||||
expect(setCurrentTab).toHaveBeenCalledTimes(1)
|
||||
expect(setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_FILE, expect.anything())
|
||||
})
|
||||
|
||||
it('should handle multiple tab clicks', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
fireEvent.click(urlTab)
|
||||
fireEvent.click(fileTab)
|
||||
fireEvent.click(urlTab)
|
||||
|
||||
expect(setCurrentTab).toHaveBeenCalledTimes(3)
|
||||
expect(setCurrentTab).toHaveBeenNthCalledWith(1, CreateFromDSLModalTab.FROM_URL, expect.anything())
|
||||
expect(setCurrentTab).toHaveBeenNthCalledWith(2, CreateFromDSLModalTab.FROM_FILE, expect.anything())
|
||||
expect(setCurrentTab).toHaveBeenNthCalledWith(3, CreateFromDSLModalTab.FROM_URL, expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for props variations
|
||||
describe('Props Variations', () => {
|
||||
it('should handle FROM_FILE as currentTab prop', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
expect(fileTab).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should handle FROM_URL as currentTab prop', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_URL}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
expect(urlTab).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should work with different setCurrentTab callback functions', () => {
|
||||
const setCurrentTab1 = jest.fn()
|
||||
const { rerender } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab1}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
|
||||
expect(setCurrentTab1).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL, expect.anything())
|
||||
|
||||
const setCurrentTab2 = jest.fn()
|
||||
rerender(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab2}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
|
||||
expect(setCurrentTab2).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL, expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle component mounting without errors', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
expect(() =>
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle component unmounting without errors', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { unmount } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle currentTab prop change', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { rerender } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Initially FROM_FILE is active
|
||||
let fileTab = screen.getByText('app.importFromDSLFile')
|
||||
expect(fileTab).toHaveClass('text-text-primary')
|
||||
|
||||
// Change to FROM_URL
|
||||
rerender(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_URL}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Now FROM_URL should be active
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
fileTab = screen.getByText('app.importFromDSLFile')
|
||||
expect(urlTab).toHaveClass('text-text-primary')
|
||||
expect(fileTab).not.toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should handle multiple rerenders', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { rerender } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_URL}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
expect(fileTab).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should maintain DOM structure after multiple interactions', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const initialTabCount = container.querySelectorAll('.cursor-pointer').length
|
||||
|
||||
// Multiple clicks
|
||||
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
|
||||
fireEvent.click(screen.getByText('app.importFromDSLFile'))
|
||||
|
||||
const afterClicksTabCount = container.querySelectorAll('.cursor-pointer').length
|
||||
expect(afterClicksTabCount).toBe(initialTabCount)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for Item component integration
|
||||
describe('Item Component Integration', () => {
|
||||
it('should render Item components with correct cursor style', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tabItems = container.querySelectorAll('.cursor-pointer')
|
||||
expect(tabItems.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should pass correct isActive prop to Item components', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
// File tab should be active
|
||||
expect(fileTab).toHaveClass('text-text-primary')
|
||||
// URL tab should be inactive
|
||||
expect(urlTab).not.toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should pass correct label to Item components', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct onClick handler to Item components', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
fireEvent.click(fileTab)
|
||||
fireEvent.click(urlTab)
|
||||
|
||||
expect(setCurrentTab).toHaveBeenCalledTimes(2)
|
||||
expect(setCurrentTab).toHaveBeenNthCalledWith(1, CreateFromDSLModalTab.FROM_FILE, expect.anything())
|
||||
expect(setCurrentTab).toHaveBeenNthCalledWith(2, CreateFromDSLModalTab.FROM_URL, expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for accessibility
|
||||
describe('Accessibility', () => {
|
||||
it('should have clickable elements for each tab', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const clickableElements = container.querySelectorAll('.cursor-pointer')
|
||||
expect(clickableElements.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should have visible text labels for each tab', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileLabel = screen.getByText('app.importFromDSLFile')
|
||||
const urlLabel = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
expect(fileLabel).toBeVisible()
|
||||
expect(urlLabel).toBeVisible()
|
||||
})
|
||||
|
||||
it('should visually distinguish active tab from inactive tabs', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
const { container } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Active tab has indicator bar
|
||||
const indicatorBars = container.querySelectorAll('.bg-util-colors-blue-brand-blue-brand-600')
|
||||
expect(indicatorBars.length).toBe(1)
|
||||
|
||||
// Active tab has different text color
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
expect(fileTab).toHaveClass('text-text-primary')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for component stability
|
||||
describe('Component Stability', () => {
|
||||
it('should handle rapid mount/unmount cycles', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { unmount } = render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
unmount()
|
||||
}
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle rapid tab switching', () => {
|
||||
const setCurrentTab = jest.fn()
|
||||
render(
|
||||
<Tab
|
||||
currentTab={CreateFromDSLModalTab.FROM_FILE}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileTab = screen.getByText('app.importFromDSLFile')
|
||||
const urlTab = screen.getByText('app.importFromDSLUrl')
|
||||
|
||||
// Rapid clicks
|
||||
for (let i = 0; i < 10; i++)
|
||||
fireEvent.click(i % 2 === 0 ? urlTab : fileTab)
|
||||
|
||||
expect(setCurrentTab).toHaveBeenCalledTimes(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,439 +0,0 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CreateFromPipeline from './index'
|
||||
|
||||
// Mock list component to avoid deep dependency issues
|
||||
jest.mock('./list', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="list">List Component</div>,
|
||||
}))
|
||||
|
||||
// Mock CreateFromDSLModal to avoid deep dependency chain
|
||||
jest.mock('./create-options/create-from-dsl-modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ show, onClose, onSuccess }: { show: boolean; onClose: () => void; onSuccess: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="dsl-modal">
|
||||
<button data-testid="dsl-modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="dsl-modal-success" onClick={onSuccess}>Import Success</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'from-url',
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
const mockReplace = jest.fn()
|
||||
const mockPush = jest.fn()
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
push: mockPush,
|
||||
}),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useInvalidDatasetList hook
|
||||
const mockInvalidDatasetList = jest.fn()
|
||||
jest.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
describe('CreateFromPipeline', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
})
|
||||
|
||||
// Tests for basic rendering
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the main container with correct className', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('relative')
|
||||
expect(mainContainer).toHaveClass('flex')
|
||||
expect(mainContainer).toHaveClass('h-[calc(100vh-56px)]')
|
||||
expect(mainContainer).toHaveClass('flex-col')
|
||||
expect(mainContainer).toHaveClass('overflow-hidden')
|
||||
expect(mainContainer).toHaveClass('rounded-t-2xl')
|
||||
expect(mainContainer).toHaveClass('border-t')
|
||||
expect(mainContainer).toHaveClass('border-effects-highlight')
|
||||
expect(mainContainer).toHaveClass('bg-background-default-subtle')
|
||||
})
|
||||
|
||||
it('should render Header component with back to knowledge text', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.creation.backToKnowledge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render List component', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
expect(screen.getByTestId('list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Footer component with import DSL button', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.creation.importDSL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Effect component with blur effect', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Effect component with correct positioning classes', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const effectElement = container.querySelector('.left-8.top-\\[-34px\\].opacity-20')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for Header component integration
|
||||
describe('Header Component Integration', () => {
|
||||
it('should render header with navigation link', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/datasets')
|
||||
})
|
||||
|
||||
it('should render back button inside header', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
const button = screen.getByRole('button', { name: '' })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).toHaveClass('rounded-full')
|
||||
})
|
||||
|
||||
it('should render header with correct styling', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const headerElement = container.querySelector('.px-16.pb-2.pt-5')
|
||||
expect(headerElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for Footer component integration
|
||||
describe('Footer Component Integration', () => {
|
||||
it('should render footer with import DSL button', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
|
||||
expect(importButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer at bottom with correct positioning classes', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const footer = container.querySelector('.absolute.bottom-0.left-0.right-0')
|
||||
expect(footer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer with backdrop blur', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const footer = container.querySelector('.backdrop-blur-\\[6px\\]')
|
||||
expect(footer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider in footer', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
// Divider renders with w-8 class
|
||||
const divider = container.querySelector('.w-8')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open import modal when import DSL button is clicked', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show import modal initially', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for Effect component integration
|
||||
describe('Effect Component Integration', () => {
|
||||
it('should render Effect with blur effect', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Effect with absolute positioning', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const effectElement = container.querySelector('.absolute.size-\\[112px\\].rounded-full')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Effect with brand color', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const effectElement = container.querySelector('.bg-util-colors-blue-brand-blue-brand-500')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Effect with custom opacity', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const effectElement = container.querySelector('.opacity-20')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for layout structure
|
||||
describe('Layout Structure', () => {
|
||||
it('should render children in correct order', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
const children = mainContainer.children
|
||||
|
||||
// Should have 4 children: Effect, Header, List, Footer
|
||||
expect(children.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should have flex column layout', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should have overflow hidden on main container', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('overflow-hidden')
|
||||
})
|
||||
|
||||
it('should have correct height calculation', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('h-[calc(100vh-56px)]')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for styling
|
||||
describe('Styling', () => {
|
||||
it('should have border styling on main container', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('border-t')
|
||||
expect(mainContainer).toHaveClass('border-effects-highlight')
|
||||
})
|
||||
|
||||
it('should have rounded top corners', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('rounded-t-2xl')
|
||||
})
|
||||
|
||||
it('should have subtle background color', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('bg-background-default-subtle')
|
||||
})
|
||||
|
||||
it('should have relative positioning for child absolute positioning', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('relative')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle component mounting without errors', () => {
|
||||
expect(() => render(<CreateFromPipeline />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle component unmounting without errors', () => {
|
||||
const { unmount } = render(<CreateFromPipeline />)
|
||||
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<CreateFromPipeline />)
|
||||
|
||||
rerender(<CreateFromPipeline />)
|
||||
rerender(<CreateFromPipeline />)
|
||||
rerender(<CreateFromPipeline />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.creation.backToKnowledge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain consistent DOM structure across rerenders', () => {
|
||||
const { container, rerender } = render(<CreateFromPipeline />)
|
||||
|
||||
const initialChildCount = (container.firstChild as HTMLElement)?.children.length
|
||||
|
||||
rerender(<CreateFromPipeline />)
|
||||
|
||||
const afterRerenderChildCount = (container.firstChild as HTMLElement)?.children.length
|
||||
expect(afterRerenderChildCount).toBe(initialChildCount)
|
||||
})
|
||||
|
||||
it('should handle remoteInstallUrl search param', () => {
|
||||
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl.yaml')
|
||||
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
// Should render without crashing when remoteInstallUrl is present
|
||||
expect(screen.getByText('datasetPipeline.creation.backToKnowledge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for accessibility
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible link for navigation', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('href', '/datasets')
|
||||
})
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2) // back button and import DSL button
|
||||
})
|
||||
|
||||
it('should use semantic structure for content', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.tagName).toBe('DIV')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for component stability
|
||||
describe('Component Stability', () => {
|
||||
it('should not cause memory leaks on unmount', () => {
|
||||
const { unmount } = render(<CreateFromPipeline />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle rapid mount/unmount cycles', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { unmount } = render(<CreateFromPipeline />)
|
||||
unmount()
|
||||
}
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for user interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle import modal when clicking import DSL button', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
// Initially modal is not shown
|
||||
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Click import DSL button
|
||||
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Modal should be shown
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
|
||||
fireEvent.click(importButton)
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Click close button
|
||||
const closeButton = screen.getByTestId('dsl-modal-close')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
// Modal should be hidden
|
||||
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close modal and redirect when close button is clicked with remoteInstallUrl', () => {
|
||||
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl.yaml')
|
||||
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Click close button
|
||||
const closeButton = screen.getByTestId('dsl-modal-close')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
// Should call replace to remove the URL param
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets/create-from-pipeline')
|
||||
})
|
||||
|
||||
it('should call invalidDatasetList when import is successful', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Click success button
|
||||
const successButton = screen.getByTestId('dsl-modal-success')
|
||||
fireEvent.click(successButton)
|
||||
|
||||
// Should call invalidDatasetList
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,842 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import List from './index'
|
||||
import type { PipelineTemplate, PipelineTemplateListResponse } from '@/models/pipeline'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
// Mock i18n context
|
||||
let mockLocale = 'en-US'
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useI18N: () => ({
|
||||
locale: mockLocale,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock global public store
|
||||
let mockEnableMarketplace = true
|
||||
jest.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => boolean) =>
|
||||
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
|
||||
}))
|
||||
|
||||
// Mock pipeline service hooks
|
||||
let mockBuiltInPipelineData: PipelineTemplateListResponse | undefined
|
||||
let mockBuiltInIsLoading = false
|
||||
let mockCustomizedPipelineData: PipelineTemplateListResponse | undefined
|
||||
let mockCustomizedIsLoading = false
|
||||
|
||||
jest.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateList: (params: { type: 'built-in' | 'customized'; language?: string }, enabled?: boolean) => {
|
||||
if (params.type === 'built-in') {
|
||||
return {
|
||||
data: enabled !== false ? mockBuiltInPipelineData : undefined,
|
||||
isLoading: mockBuiltInIsLoading,
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: mockCustomizedPipelineData,
|
||||
isLoading: mockCustomizedIsLoading,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock CreateCard component to avoid deep service dependencies
|
||||
jest.mock('./create-card', () => ({
|
||||
__esModule: true,
|
||||
default: () => (
|
||||
<div data-testid="create-card" className="h-[132px] cursor-pointer">
|
||||
<span>datasetPipeline.creation.createFromScratch.title</span>
|
||||
<span>datasetPipeline.creation.createFromScratch.description</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock TemplateCard component to avoid deep service dependencies
|
||||
jest.mock('./template-card', () => ({
|
||||
__esModule: true,
|
||||
default: ({ pipeline, type, showMoreOperations }: {
|
||||
pipeline: PipelineTemplate
|
||||
type: 'built-in' | 'customized'
|
||||
showMoreOperations?: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid={`template-card-${pipeline.id}`}
|
||||
data-type={type}
|
||||
data-show-more={showMoreOperations}
|
||||
className="h-[132px]"
|
||||
>
|
||||
<span data-testid={`template-name-${pipeline.id}`}>{pipeline.name}</span>
|
||||
<span data-testid={`template-description-${pipeline.id}`}>{pipeline.description}</span>
|
||||
<span data-testid={`template-chunk-structure-${pipeline.id}`}>{pipeline.chunk_structure}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Factory function for creating mock pipeline templates
|
||||
const createMockPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
|
||||
id: 'template-1',
|
||||
name: 'Test Pipeline',
|
||||
description: 'Test pipeline description',
|
||||
icon: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🔧',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
position: 1,
|
||||
chunk_structure: ChunkingMode.text,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockLocale = 'en-US'
|
||||
mockEnableMarketplace = true
|
||||
mockBuiltInPipelineData = undefined
|
||||
mockBuiltInIsLoading = false
|
||||
mockCustomizedPipelineData = undefined
|
||||
mockCustomizedIsLoading = false
|
||||
})
|
||||
|
||||
/**
|
||||
* List Component Container
|
||||
* Tests for the main List wrapper component rendering and styling
|
||||
*/
|
||||
describe('List Component Container', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the main container as a div element', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.tagName).toBe('DIV')
|
||||
})
|
||||
|
||||
it('should render the main container with grow class for flex expansion', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('grow')
|
||||
})
|
||||
|
||||
it('should render the main container with gap-y-1 class for vertical spacing', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('gap-y-1')
|
||||
})
|
||||
|
||||
it('should render the main container with overflow-y-auto for vertical scrolling', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('overflow-y-auto')
|
||||
})
|
||||
|
||||
it('should render the main container with horizontal padding px-16', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('px-16')
|
||||
})
|
||||
|
||||
it('should render the main container with bottom padding pb-[60px]', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('pb-[60px]')
|
||||
})
|
||||
|
||||
it('should render the main container with top padding pt-1', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('pt-1')
|
||||
})
|
||||
|
||||
it('should have all required styling classes applied', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('grow')
|
||||
expect(mainContainer).toHaveClass('gap-y-1')
|
||||
expect(mainContainer).toHaveClass('overflow-y-auto')
|
||||
expect(mainContainer).toHaveClass('px-16')
|
||||
expect(mainContainer).toHaveClass('pb-[60px]')
|
||||
expect(mainContainer).toHaveClass('pt-1')
|
||||
})
|
||||
|
||||
it('should render both BuiltInPipelineList and CustomizedList as children when customized data exists', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-child-test' })],
|
||||
}
|
||||
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
// BuiltInPipelineList always renders (1 child)
|
||||
// CustomizedList renders when it has data (adds more children: title + grid)
|
||||
// So we should have at least 2 children when customized data exists
|
||||
expect(mainContainer.children.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should render only BuiltInPipelineList when customized list is empty', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
// CustomizedList returns null when empty, so only BuiltInPipelineList renders
|
||||
expect(mainContainer.children.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* BuiltInPipelineList Integration
|
||||
* Tests for built-in pipeline templates list including CreateCard and TemplateCards
|
||||
*/
|
||||
describe('BuiltInPipelineList Integration', () => {
|
||||
it('should render CreateCard component', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.creation.createFromScratch.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.creation.createFromScratch.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render grid container with correct responsive classes', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const gridContainer = container.querySelector('.grid')
|
||||
expect(gridContainer).toBeInTheDocument()
|
||||
expect(gridContainer).toHaveClass('grid-cols-1')
|
||||
expect(gridContainer).toHaveClass('gap-3')
|
||||
expect(gridContainer).toHaveClass('py-2')
|
||||
expect(gridContainer).toHaveClass('sm:grid-cols-2')
|
||||
expect(gridContainer).toHaveClass('md:grid-cols-3')
|
||||
expect(gridContainer).toHaveClass('lg:grid-cols-4')
|
||||
})
|
||||
|
||||
it('should not render built-in template cards when loading', () => {
|
||||
mockBuiltInIsLoading = true
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByTestId('template-card-template-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render built-in template cards when data is loaded', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'built-1', name: 'Pipeline 1' }),
|
||||
createMockPipelineTemplate({ id: 'built-2', name: 'Pipeline 2' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-card-built-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('template-card-built-2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pipeline 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pipeline 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty state when no built-in templates (only CreateCard visible)', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/^template-card-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined pipeline_templates gracefully', () => {
|
||||
mockBuiltInPipelineData = {} as PipelineTemplateListResponse
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass type=built-in to TemplateCard', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'built-type-test' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
const templateCard = screen.getByTestId('template-card-built-type-test')
|
||||
expect(templateCard).toHaveAttribute('data-type', 'built-in')
|
||||
})
|
||||
|
||||
it('should pass showMoreOperations=false to built-in TemplateCards', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'built-ops-test' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
const templateCard = screen.getByTestId('template-card-built-ops-test')
|
||||
expect(templateCard).toHaveAttribute('data-show-more', 'false')
|
||||
})
|
||||
|
||||
it('should render multiple built-in templates in order', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'first', name: 'First' }),
|
||||
createMockPipelineTemplate({ id: 'second', name: 'Second' }),
|
||||
createMockPipelineTemplate({ id: 'third', name: 'Third' }),
|
||||
],
|
||||
}
|
||||
|
||||
const { container } = render(<List />)
|
||||
|
||||
const gridContainer = container.querySelector('.grid')
|
||||
const cards = gridContainer?.querySelectorAll('[data-testid^="template-card-"]')
|
||||
|
||||
expect(cards?.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* CustomizedList Integration
|
||||
* Tests for customized pipeline templates list including conditional rendering
|
||||
*/
|
||||
describe('CustomizedList Integration', () => {
|
||||
it('should return null when loading', () => {
|
||||
mockCustomizedIsLoading = true
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when list is empty', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when pipeline_templates is undefined', () => {
|
||||
mockCustomizedPipelineData = {} as PipelineTemplateListResponse
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render customized section title when data is available', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-1' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.templates.customized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render customized title with correct styling', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
const { container } = render(<List />)
|
||||
|
||||
const title = container.querySelector('.system-sm-semibold-uppercase')
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(title).toHaveClass('pt-2')
|
||||
expect(title).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render customized template cards', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'custom-1', name: 'Custom Pipeline 1' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-card-custom-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom Pipeline 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple customized templates', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'custom-1', name: 'Custom 1' }),
|
||||
createMockPipelineTemplate({ id: 'custom-2', name: 'Custom 2' }),
|
||||
createMockPipelineTemplate({ id: 'custom-3', name: 'Custom 3' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Custom 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass type=customized to TemplateCard', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-type-test' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
const templateCard = screen.getByTestId('template-card-custom-type-test')
|
||||
expect(templateCard).toHaveAttribute('data-type', 'customized')
|
||||
})
|
||||
|
||||
it('should not pass showMoreOperations prop to customized TemplateCards (defaults to true)', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-ops-test' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
const templateCard = screen.getByTestId('template-card-custom-ops-test')
|
||||
// showMoreOperations is not passed, so data-show-more should be undefined
|
||||
expect(templateCard).not.toHaveAttribute('data-show-more', 'false')
|
||||
})
|
||||
|
||||
it('should render customized grid with responsive classes', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
const { container } = render(<List />)
|
||||
|
||||
// Find the second grid (customized list grid)
|
||||
const grids = container.querySelectorAll('.grid')
|
||||
expect(grids.length).toBe(2) // built-in grid and customized grid
|
||||
expect(grids[1]).toHaveClass('grid-cols-1')
|
||||
expect(grids[1]).toHaveClass('gap-3')
|
||||
expect(grids[1]).toHaveClass('py-2')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Language Handling
|
||||
* Tests for locale-based language selection in BuiltInPipelineList
|
||||
*/
|
||||
describe('Language Handling', () => {
|
||||
it('should use zh-Hans locale when set', () => {
|
||||
mockLocale = 'zh-Hans'
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use ja-JP locale when set', () => {
|
||||
mockLocale = 'ja-JP'
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default language for unsupported locales', () => {
|
||||
mockLocale = 'fr-FR'
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ko-KR locale (fallback)', () => {
|
||||
mockLocale = 'ko-KR'
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Marketplace Feature Flag
|
||||
* Tests for enable_marketplace system feature affecting built-in templates fetching
|
||||
*/
|
||||
describe('Marketplace Feature Flag', () => {
|
||||
it('should not fetch built-in templates when marketplace is disabled', () => {
|
||||
mockEnableMarketplace = false
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ name: 'Should Not Show' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
// CreateCard should render but template should not (enabled=false)
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Should Not Show')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fetch built-in templates when marketplace is enabled', () => {
|
||||
mockEnableMarketplace = true
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'marketplace', name: 'Marketplace Template' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Marketplace Template')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Template Data Rendering
|
||||
* Tests for correct rendering of template properties (name, description, chunk_structure)
|
||||
*/
|
||||
describe('Template Data Rendering', () => {
|
||||
it('should render template name correctly', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'name-test', name: 'My Custom Pipeline Name' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-name-name-test')).toHaveTextContent('My Custom Pipeline Name')
|
||||
})
|
||||
|
||||
it('should render template description correctly', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'desc-test', description: 'This is a detailed description' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-description-desc-test')).toHaveTextContent('This is a detailed description')
|
||||
})
|
||||
|
||||
it('should render template with text chunk structure', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'chunk-text', chunk_structure: ChunkingMode.text }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-chunk-structure-chunk-text')).toHaveTextContent(ChunkingMode.text)
|
||||
})
|
||||
|
||||
it('should render template with qa chunk structure', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'chunk-qa', chunk_structure: ChunkingMode.qa }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-chunk-structure-chunk-qa')).toHaveTextContent(ChunkingMode.qa)
|
||||
})
|
||||
|
||||
it('should render template with parentChild chunk structure', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'chunk-pc', chunk_structure: ChunkingMode.parentChild }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-chunk-structure-chunk-pc')).toHaveTextContent(ChunkingMode.parentChild)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Edge Cases
|
||||
* Tests for boundary conditions, special characters, and component lifecycle
|
||||
*/
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle component mounting without errors', () => {
|
||||
expect(() => render(<List />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle component unmounting without errors', () => {
|
||||
const { unmount } = render(<List />)
|
||||
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle multiple rerenders without issues', () => {
|
||||
const { rerender } = render(<List />)
|
||||
|
||||
rerender(<List />)
|
||||
rerender(<List />)
|
||||
rerender(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain consistent DOM structure across rerenders', () => {
|
||||
const { container, rerender } = render(<List />)
|
||||
|
||||
const initialChildCount = (container.firstChild as HTMLElement)?.children.length
|
||||
|
||||
rerender(<List />)
|
||||
|
||||
const afterRerenderChildCount = (container.firstChild as HTMLElement)?.children.length
|
||||
expect(afterRerenderChildCount).toBe(initialChildCount)
|
||||
})
|
||||
|
||||
it('should handle concurrent built-in and customized templates', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'built-in-1', name: 'Built-in Template' }),
|
||||
],
|
||||
}
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'custom-1', name: 'Customized Template' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Built-in Template')).toBeInTheDocument()
|
||||
expect(screen.getByText('Customized Template')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.templates.customized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle templates with long names gracefully', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'long-name', name: longName }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-name-long-name')).toHaveTextContent(longName)
|
||||
})
|
||||
|
||||
it('should handle templates with empty description', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'empty-desc', description: '' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-description-empty-desc')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle templates with special characters in name', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [
|
||||
createMockPipelineTemplate({ id: 'special', name: 'Test <>&"\'Pipeline' }),
|
||||
],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('template-name-special')).toHaveTextContent('Test <>&"\'Pipeline')
|
||||
})
|
||||
|
||||
it('should handle rapid mount/unmount cycles', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { unmount } = render(<List />)
|
||||
unmount()
|
||||
}
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Loading States
|
||||
* Tests for component behavior during data loading
|
||||
*/
|
||||
describe('Loading States', () => {
|
||||
it('should handle both lists loading simultaneously', () => {
|
||||
mockBuiltInIsLoading = true
|
||||
mockCustomizedIsLoading = true
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle built-in loading while customized is loaded', () => {
|
||||
mockBuiltInIsLoading = true
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-only', name: 'Customized Only' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Customized Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle customized loading while built-in is loaded', () => {
|
||||
mockCustomizedIsLoading = true
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'built-only', name: 'Built-in Only' })],
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Built-in Only')).toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should transition from loading to loaded state', () => {
|
||||
mockBuiltInIsLoading = true
|
||||
const { rerender } = render(<List />)
|
||||
|
||||
expect(screen.queryByTestId('template-card-transition')).not.toBeInTheDocument()
|
||||
|
||||
// Simulate data loaded
|
||||
mockBuiltInIsLoading = false
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'transition', name: 'After Load' })],
|
||||
}
|
||||
|
||||
rerender(<List />)
|
||||
|
||||
expect(screen.getByText('After Load')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Component Stability
|
||||
* Tests for consistent rendering and state management
|
||||
*/
|
||||
describe('Component Stability', () => {
|
||||
it('should render same structure on initial render and rerender', () => {
|
||||
const { container, rerender } = render(<List />)
|
||||
|
||||
const initialHTML = container.innerHTML
|
||||
|
||||
rerender(<List />)
|
||||
|
||||
const rerenderHTML = container.innerHTML
|
||||
expect(rerenderHTML).toBe(initialHTML)
|
||||
})
|
||||
|
||||
it('should not cause memory leaks on unmount', () => {
|
||||
const { unmount } = render(<List />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle state changes correctly', () => {
|
||||
mockBuiltInPipelineData = undefined
|
||||
|
||||
const { rerender } = render(<List />)
|
||||
|
||||
// Add data
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate({ id: 'state-test', name: 'State Test' })],
|
||||
}
|
||||
|
||||
rerender(<List />)
|
||||
|
||||
expect(screen.getByText('State Test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Accessibility
|
||||
* Tests for semantic structure and keyboard navigation support
|
||||
*/
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic div structure for main container', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.tagName).toBe('DIV')
|
||||
})
|
||||
|
||||
it('should have scrollable container for keyboard navigation', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('overflow-y-auto')
|
||||
})
|
||||
|
||||
it('should have appropriate spacing for readability', () => {
|
||||
const { container } = render(<List />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('gap-y-1')
|
||||
expect(mainContainer).toHaveClass('px-16')
|
||||
})
|
||||
|
||||
it('should render grid structure for template cards', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: [createMockPipelineTemplate()],
|
||||
}
|
||||
|
||||
const { container } = render(<List />)
|
||||
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Large Datasets
|
||||
* Tests for performance with many templates
|
||||
*/
|
||||
describe('Large Datasets', () => {
|
||||
it('should handle many built-in templates', () => {
|
||||
mockBuiltInPipelineData = {
|
||||
pipeline_templates: Array.from({ length: 50 }, (_, i) =>
|
||||
createMockPipelineTemplate({ id: `built-${i}`, name: `Pipeline ${i}` }),
|
||||
),
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Pipeline 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pipeline 49')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle many customized templates', () => {
|
||||
mockCustomizedPipelineData = {
|
||||
pipeline_templates: Array.from({ length: 50 }, (_, i) =>
|
||||
createMockPipelineTemplate({ id: `custom-${i}`, name: `Custom ${i}` }),
|
||||
),
|
||||
}
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Custom 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom 49')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,786 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Details from './index'
|
||||
import type { PipelineTemplateByIdResponse } from '@/models/pipeline'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import type { Edge, Node, Viewport } from 'reactflow'
|
||||
|
||||
// Mock usePipelineTemplateById hook
|
||||
let mockPipelineTemplateData: PipelineTemplateByIdResponse | undefined
|
||||
let mockIsLoading = false
|
||||
|
||||
jest.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateById: (params: { template_id: string; type: 'customized' | 'built-in' }, enabled: boolean) => ({
|
||||
data: enabled ? mockPipelineTemplateData : undefined,
|
||||
isLoading: mockIsLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock WorkflowPreview component to avoid deep dependencies
|
||||
jest.mock('@/app/components/workflow/workflow-preview', () => ({
|
||||
__esModule: true,
|
||||
default: ({ nodes, edges, viewport, className }: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport: Viewport
|
||||
className?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="workflow-preview"
|
||||
data-nodes-count={nodes?.length ?? 0}
|
||||
data-edges-count={edges?.length ?? 0}
|
||||
data-viewport-zoom={viewport?.zoom}
|
||||
className={className}
|
||||
>
|
||||
WorkflowPreview
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Factory function for creating mock pipeline template response
|
||||
const createMockPipelineTemplate = (
|
||||
overrides: Partial<PipelineTemplateByIdResponse> = {},
|
||||
): PipelineTemplateByIdResponse => ({
|
||||
id: 'test-template-id',
|
||||
name: 'Test Pipeline Template',
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
description: 'Test pipeline description for testing purposes',
|
||||
chunk_structure: ChunkingMode.text,
|
||||
export_data: '{}',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
|
||||
] as unknown as Node[],
|
||||
edges: [] as Edge[],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
created_by: 'Test Author',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Default props factory
|
||||
const createDefaultProps = () => ({
|
||||
id: 'test-id',
|
||||
type: 'built-in' as const,
|
||||
onApplyTemplate: jest.fn(),
|
||||
onClose: jest.fn(),
|
||||
})
|
||||
|
||||
describe('Details', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockPipelineTemplateData = undefined
|
||||
mockIsLoading = false
|
||||
})
|
||||
|
||||
/**
|
||||
* Loading State Tests
|
||||
* Tests for component behavior when data is loading or undefined
|
||||
*/
|
||||
describe('Loading State', () => {
|
||||
it('should render Loading component when pipelineTemplateInfo is undefined', () => {
|
||||
mockPipelineTemplateData = undefined
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
// Loading component renders a spinner SVG with spin-animation class
|
||||
const spinner = container.querySelector('.spin-animation')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Loading component when data is still loading', () => {
|
||||
mockIsLoading = true
|
||||
mockPipelineTemplateData = undefined
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
// Loading component renders a spinner SVG with spin-animation class
|
||||
const spinner = container.querySelector('.spin-animation')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render main content while loading', () => {
|
||||
mockPipelineTemplateData = undefined
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetPipeline.operations.useTemplate')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Rendering Tests
|
||||
* Tests for correct rendering when data is available
|
||||
*/
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when data is available', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the main container with flex layout', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('flex')
|
||||
expect(mainContainer).toHaveClass('h-full')
|
||||
})
|
||||
|
||||
it('should render WorkflowPreview component', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass graph data to WorkflowPreview', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: '1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: '2', type: 'custom', position: { x: 100, y: 100 }, data: {} },
|
||||
] as unknown as Node[],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2' },
|
||||
] as unknown as Edge[],
|
||||
viewport: { x: 10, y: 20, zoom: 1.5 },
|
||||
},
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const preview = screen.getByTestId('workflow-preview')
|
||||
expect(preview).toHaveAttribute('data-nodes-count', '2')
|
||||
expect(preview).toHaveAttribute('data-edges-count', '1')
|
||||
expect(preview).toHaveAttribute('data-viewport-zoom', '1.5')
|
||||
})
|
||||
|
||||
it('should render template name', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'My Test Pipeline' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('My Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render template description', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ description: 'This is a test description' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('This is a test description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created_by information when available', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ created_by: 'John Doe' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// The translation key includes the author
|
||||
expect(screen.getByText('datasetPipeline.details.createdBy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render created_by when not available', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ created_by: '' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.queryByText(/createdBy/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Use Template" button', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.operations.useTemplate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: '' })
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render structure section title', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render structure tooltip', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// Tooltip component should be rendered
|
||||
expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Event Handler Tests
|
||||
* Tests for user interactions and callback functions
|
||||
*/
|
||||
describe('Event Handlers', () => {
|
||||
it('should call onApplyTemplate when "Use Template" button is clicked', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
|
||||
fireEvent.click(useTemplateButton!)
|
||||
|
||||
expect(props.onApplyTemplate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
// Find the close button (the one with RiCloseLine icon)
|
||||
const closeButton = container.querySelector('button.absolute.right-4')
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call handlers on multiple clicks (each click should trigger once)', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
|
||||
fireEvent.click(useTemplateButton!)
|
||||
fireEvent.click(useTemplateButton!)
|
||||
fireEvent.click(useTemplateButton!)
|
||||
|
||||
expect(props.onApplyTemplate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Props Variations Tests
|
||||
* Tests for different prop combinations
|
||||
*/
|
||||
describe('Props Variations', () => {
|
||||
it('should handle built-in type', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = { ...createDefaultProps(), type: 'built-in' as const }
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle customized type', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = { ...createDefaultProps(), type: 'customized' as const }
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle different template IDs', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = { ...createDefaultProps(), id: 'unique-template-123' }
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* App Icon Memoization Tests
|
||||
* Tests for the useMemo logic that computes appIcon
|
||||
*/
|
||||
describe('App Icon Memoization', () => {
|
||||
it('should use default emoji icon when pipelineTemplateInfo is undefined', () => {
|
||||
mockPipelineTemplateData = undefined
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// Loading state - no AppIcon rendered
|
||||
expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle emoji icon type', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🚀',
|
||||
icon_background: '#E6F4FF',
|
||||
icon_url: '',
|
||||
},
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// AppIcon should be rendered with emoji
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle image icon type', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
icon_info: {
|
||||
icon_type: 'image',
|
||||
icon: 'file-id-123',
|
||||
icon_background: '',
|
||||
icon_url: 'https://example.com/image.png',
|
||||
},
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle image icon type with empty url and icon (fallback branch)', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
icon_info: {
|
||||
icon_type: 'image',
|
||||
icon: '', // empty string - triggers || '' fallback
|
||||
icon_background: '',
|
||||
icon_url: '', // empty string - triggers || '' fallback
|
||||
},
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// Component should still render without errors
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing icon properties gracefully', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '',
|
||||
icon_background: '',
|
||||
icon_url: '',
|
||||
},
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
expect(() => render(<Details {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Chunk Structure Tests
|
||||
* Tests for different chunk_structure values and ChunkStructureCard rendering
|
||||
*/
|
||||
describe('Chunk Structure', () => {
|
||||
it('should render ChunkStructureCard for text chunk structure', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
chunk_structure: ChunkingMode.text,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// ChunkStructureCard should be rendered
|
||||
expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
|
||||
// General option title
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChunkStructureCard for parentChild chunk structure', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
chunk_structure: ChunkingMode.parentChild,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('Parent-Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChunkStructureCard for qa chunk structure', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
chunk_structure: ChunkingMode.qa,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('Q&A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Edge Cases Tests
|
||||
* Tests for boundary conditions and unusual inputs
|
||||
*/
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: '' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
expect(() => render(<Details {...props} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle empty description', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ description: '' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long name', () => {
|
||||
const longName = 'A'.repeat(200)
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: longName })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const nameElement = screen.getByText(longName)
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should handle very long description', () => {
|
||||
const longDesc = 'B'.repeat(1000)
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ description: longDesc })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText(longDesc)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
name: 'Test <>&"\'Pipeline @#$%^&*()',
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('Test <>&"\'Pipeline @#$%^&*()')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
name: '测试管道 🚀 テスト',
|
||||
description: '这是一个测试描述 日本語テスト',
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('测试管道 🚀 テスト')).toBeInTheDocument()
|
||||
expect(screen.getByText('这是一个测试描述 日本語テスト')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty graph nodes and edges', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const preview = screen.getByTestId('workflow-preview')
|
||||
expect(preview).toHaveAttribute('data-nodes-count', '0')
|
||||
expect(preview).toHaveAttribute('data-edges-count', '0')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Component Memoization Tests
|
||||
* Tests for React.memo behavior
|
||||
*/
|
||||
describe('Component Memoization', () => {
|
||||
it('should render correctly after rerender with same props', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('Test Pipeline Template')).toBeInTheDocument()
|
||||
|
||||
rerender(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('Test Pipeline Template')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when id prop changes', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'First Template' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('First Template')).toBeInTheDocument()
|
||||
|
||||
// Change the id prop which should trigger a rerender
|
||||
// Update mock data for the new id
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'Second Template' })
|
||||
rerender(<Details {...props} id="new-id" />)
|
||||
|
||||
expect(screen.getByText('Second Template')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle callback reference changes', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<Details {...props} />)
|
||||
|
||||
const newOnApplyTemplate = jest.fn()
|
||||
rerender(<Details {...props} onApplyTemplate={newOnApplyTemplate} />)
|
||||
|
||||
const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
|
||||
fireEvent.click(useTemplateButton!)
|
||||
|
||||
expect(newOnApplyTemplate).toHaveBeenCalledTimes(1)
|
||||
expect(props.onApplyTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Component Structure Tests
|
||||
* Tests for DOM structure and layout
|
||||
*/
|
||||
describe('Component Structure', () => {
|
||||
it('should have left panel for workflow preview', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
const leftPanel = container.querySelector('.grow.items-center.justify-center')
|
||||
expect(leftPanel).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have right panel with fixed width', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<Details {...props} />)
|
||||
|
||||
const rightPanel = container.querySelector('.w-\\[360px\\]')
|
||||
expect(rightPanel).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have primary button variant for Use Template', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const button = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
|
||||
// Button should have primary styling
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have title attribute for truncation tooltip', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'My Pipeline Name' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const nameElement = screen.getByText('My Pipeline Name')
|
||||
expect(nameElement).toHaveAttribute('title', 'My Pipeline Name')
|
||||
})
|
||||
|
||||
it('should have title attribute on created_by for truncation', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ created_by: 'Author Name' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const createdByElement = screen.getByText('datasetPipeline.details.createdBy')
|
||||
expect(createdByElement).toHaveAttribute('title', 'Author Name')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Component Lifecycle Tests
|
||||
* Tests for mount/unmount behavior
|
||||
*/
|
||||
describe('Component Lifecycle', () => {
|
||||
it('should mount without errors', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
expect(() => render(<Details {...props} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should unmount without errors', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { unmount } = render(<Details {...props} />)
|
||||
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle rapid mount/unmount cycles', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { unmount } = render(<Details {...props} />)
|
||||
unmount()
|
||||
}
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should transition from loading to loaded state', () => {
|
||||
mockPipelineTemplateData = undefined
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender, container } = render(<Details {...props} />)
|
||||
|
||||
// Loading component renders a spinner SVG with spin-animation class
|
||||
const spinner = container.querySelector('.spin-animation')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
|
||||
// Simulate data loaded - need to change props to trigger rerender with React.memo
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
rerender(<Details {...props} id="loaded-id" />)
|
||||
|
||||
expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Styling Tests
|
||||
* Tests for CSS classes and visual styling
|
||||
*/
|
||||
describe('Styling', () => {
|
||||
it('should apply overflow-hidden rounded-2xl to WorkflowPreview container', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const preview = screen.getByTestId('workflow-preview')
|
||||
expect(preview).toHaveClass('overflow-hidden')
|
||||
expect(preview).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('should apply correct typography classes to template name', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const nameElement = screen.getByText('Test Pipeline Template')
|
||||
expect(nameElement).toHaveClass('system-md-semibold')
|
||||
expect(nameElement).toHaveClass('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should apply correct styling to description', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const description = screen.getByText('Test pipeline description for testing purposes')
|
||||
expect(description).toHaveClass('system-sm-regular')
|
||||
expect(description).toHaveClass('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should apply correct styling to structure title', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
const structureTitle = screen.getByText('datasetPipeline.details.structure')
|
||||
expect(structureTitle).toHaveClass('system-sm-semibold-uppercase')
|
||||
expect(structureTitle).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* API Hook Integration Tests
|
||||
* Tests for usePipelineTemplateById hook behavior
|
||||
*/
|
||||
describe('API Hook Integration', () => {
|
||||
it('should pass correct params to usePipelineTemplateById for built-in type', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = { ...createDefaultProps(), id: 'test-id-123', type: 'built-in' as const }
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
// The hook should be called with the correct parameters
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct params to usePipelineTemplateById for customized type', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate()
|
||||
const props = { ...createDefaultProps(), id: 'custom-id-456', type: 'customized' as const }
|
||||
|
||||
render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle data refetch on id change', () => {
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'First Template' })
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<Details {...props} />)
|
||||
|
||||
expect(screen.getByText('First Template')).toBeInTheDocument()
|
||||
|
||||
// Change id and update mock data
|
||||
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'Second Template' })
|
||||
rerender(<Details {...props} id="new-id" />)
|
||||
|
||||
expect(screen.getByText('Second Template')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,965 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import TemplateCard from './index'
|
||||
import type { PipelineTemplate, PipelineTemplateByIdResponse } from '@/models/pipeline'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
// Mock Next.js router
|
||||
const mockPush = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockCreateDataset: jest.Mock
|
||||
let mockDeleteTemplate: jest.Mock
|
||||
let mockExportTemplateDSL: jest.Mock
|
||||
let mockInvalidCustomizedTemplateList: jest.Mock
|
||||
let mockInvalidDatasetList: jest.Mock
|
||||
let mockHandleCheckPluginDependencies: jest.Mock
|
||||
let mockIsExporting = false
|
||||
|
||||
// Mock service hooks
|
||||
let mockPipelineTemplateByIdData: PipelineTemplateByIdResponse | undefined
|
||||
let mockRefetch: jest.Mock
|
||||
|
||||
jest.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateById: () => ({
|
||||
data: mockPipelineTemplateByIdData,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useDeleteTemplate: () => ({
|
||||
mutateAsync: mockDeleteTemplate,
|
||||
}),
|
||||
useExportTemplateDSL: () => ({
|
||||
mutateAsync: mockExportTemplateDSL,
|
||||
isPending: mockIsExporting,
|
||||
}),
|
||||
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/knowledge/use-create-dataset', () => ({
|
||||
useCreatePipelineDatasetFromCustomized: () => ({
|
||||
mutateAsync: mockCreateDataset,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock downloadFile
|
||||
const mockDownloadFile = jest.fn()
|
||||
jest.mock('@/utils/format', () => ({
|
||||
downloadFile: (params: { data: Blob; fileName: string }) => mockDownloadFile(params),
|
||||
}))
|
||||
|
||||
// Mock trackEvent
|
||||
const mockTrackEvent = jest.fn()
|
||||
jest.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (name: string, params: Record<string, unknown>) => mockTrackEvent(name, params),
|
||||
}))
|
||||
|
||||
// Mock child components to simplify testing
|
||||
jest.mock('./content', () => ({
|
||||
__esModule: true,
|
||||
default: ({ name, description, iconInfo, chunkStructure }: {
|
||||
name: string
|
||||
description: string
|
||||
iconInfo: { icon_type: string }
|
||||
chunkStructure: string
|
||||
}) => (
|
||||
<div data-testid="content">
|
||||
<span data-testid="content-name">{name}</span>
|
||||
<span data-testid="content-description">{description}</span>
|
||||
<span data-testid="content-icon-type">{iconInfo.icon_type}</span>
|
||||
<span data-testid="content-chunk-structure">{chunkStructure}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('./actions', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onApplyTemplate,
|
||||
handleShowTemplateDetails,
|
||||
showMoreOperations,
|
||||
openEditModal,
|
||||
handleExportDSL,
|
||||
handleDelete,
|
||||
}: {
|
||||
onApplyTemplate: () => void
|
||||
handleShowTemplateDetails: () => void
|
||||
showMoreOperations: boolean
|
||||
openEditModal: () => void
|
||||
handleExportDSL: () => void
|
||||
handleDelete: () => void
|
||||
}) => (
|
||||
<div data-testid="actions" data-show-more={showMoreOperations}>
|
||||
<button data-testid="apply-template-btn" onClick={onApplyTemplate}>Apply</button>
|
||||
<button data-testid="show-details-btn" onClick={handleShowTemplateDetails}>Details</button>
|
||||
<button data-testid="edit-modal-btn" onClick={openEditModal}>Edit</button>
|
||||
<button data-testid="export-dsl-btn" onClick={handleExportDSL}>Export</button>
|
||||
<button data-testid="delete-btn" onClick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('./details', () => ({
|
||||
__esModule: true,
|
||||
default: ({ id, type, onClose, onApplyTemplate }: {
|
||||
id: string
|
||||
type: string
|
||||
onClose: () => void
|
||||
onApplyTemplate: () => void
|
||||
}) => (
|
||||
<div data-testid="details-modal">
|
||||
<span data-testid="details-id">{id}</span>
|
||||
<span data-testid="details-type">{type}</span>
|
||||
<button data-testid="details-close-btn" onClick={onClose}>Close</button>
|
||||
<button data-testid="details-apply-btn" onClick={onApplyTemplate}>Apply</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('./edit-pipeline-info', () => ({
|
||||
__esModule: true,
|
||||
default: ({ pipeline, onClose }: {
|
||||
pipeline: PipelineTemplate
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="edit-pipeline-modal">
|
||||
<span data-testid="edit-pipeline-id">{pipeline.id}</span>
|
||||
<button data-testid="edit-close-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Factory function for creating mock pipeline template
|
||||
const createMockPipeline = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
|
||||
id: 'test-pipeline-id',
|
||||
name: 'Test Pipeline',
|
||||
description: 'Test pipeline description',
|
||||
icon: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
position: 1,
|
||||
chunk_structure: ChunkingMode.text,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Factory function for creating mock pipeline template by id response
|
||||
const createMockPipelineByIdResponse = (
|
||||
overrides: Partial<PipelineTemplateByIdResponse> = {},
|
||||
): PipelineTemplateByIdResponse => ({
|
||||
id: 'test-pipeline-id',
|
||||
name: 'Test Pipeline',
|
||||
description: 'Test pipeline description',
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
chunk_structure: ChunkingMode.text,
|
||||
export_data: 'yaml_content_here',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
created_by: 'Test Author',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Default props factory
|
||||
const createDefaultProps = () => ({
|
||||
pipeline: createMockPipeline(),
|
||||
type: 'built-in' as const,
|
||||
showMoreOperations: true,
|
||||
})
|
||||
|
||||
describe('TemplateCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockPipelineTemplateByIdData = undefined
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn()
|
||||
mockDeleteTemplate = jest.fn()
|
||||
mockExportTemplateDSL = jest.fn()
|
||||
mockInvalidCustomizedTemplateList = jest.fn()
|
||||
mockInvalidDatasetList = jest.fn()
|
||||
mockHandleCheckPluginDependencies = jest.fn()
|
||||
mockIsExporting = false
|
||||
})
|
||||
|
||||
/**
|
||||
* Rendering Tests
|
||||
* Tests for basic component rendering
|
||||
*/
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('actions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Content component with correct props', () => {
|
||||
const pipeline = createMockPipeline({
|
||||
name: 'My Pipeline',
|
||||
description: 'My description',
|
||||
chunk_structure: ChunkingMode.qa,
|
||||
})
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('My Pipeline')
|
||||
expect(screen.getByTestId('content-description')).toHaveTextContent('My description')
|
||||
expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(ChunkingMode.qa)
|
||||
})
|
||||
|
||||
it('should render Actions component with showMoreOperations=true by default', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
const actions = screen.getByTestId('actions')
|
||||
expect(actions).toHaveAttribute('data-show-more', 'true')
|
||||
})
|
||||
|
||||
it('should render Actions component with showMoreOperations=false when specified', () => {
|
||||
const props = { ...createDefaultProps(), showMoreOperations: false }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
const actions = screen.getByTestId('actions')
|
||||
expect(actions).toHaveAttribute('data-show-more', 'false')
|
||||
})
|
||||
|
||||
it('should have correct container styling', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { container } = render(<TemplateCard {...props} />)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('group')
|
||||
expect(card).toHaveClass('relative')
|
||||
expect(card).toHaveClass('flex')
|
||||
expect(card).toHaveClass('h-[132px]')
|
||||
expect(card).toHaveClass('cursor-pointer')
|
||||
expect(card).toHaveClass('rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Props Variations Tests
|
||||
* Tests for different prop combinations
|
||||
*/
|
||||
describe('Props Variations', () => {
|
||||
it('should handle built-in type', () => {
|
||||
const props = { ...createDefaultProps(), type: 'built-in' as const }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle customized type', () => {
|
||||
const props = { ...createDefaultProps(), type: 'customized' as const }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle different pipeline data', () => {
|
||||
const pipeline = createMockPipeline({
|
||||
id: 'unique-id-123',
|
||||
name: 'Unique Pipeline',
|
||||
description: 'Unique description',
|
||||
chunk_structure: ChunkingMode.parentChild,
|
||||
})
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('Unique Pipeline')
|
||||
expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(ChunkingMode.parentChild)
|
||||
})
|
||||
|
||||
it('should handle image icon type', () => {
|
||||
const pipeline = createMockPipeline({
|
||||
icon: {
|
||||
icon_type: 'image',
|
||||
icon: 'file-id',
|
||||
icon_background: '',
|
||||
icon_url: 'https://example.com/image.png',
|
||||
},
|
||||
})
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-icon-type')).toHaveTextContent('image')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* State Management Tests
|
||||
* Tests for modal state (showEditModal, showDeleteConfirm, showDetailModal)
|
||||
*/
|
||||
describe('State Management', () => {
|
||||
it('should not show edit modal initially', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit modal when openEditModal is called', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-modal-btn'))
|
||||
|
||||
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close edit modal when onClose is called', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-modal-btn'))
|
||||
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-close-btn'))
|
||||
expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show delete confirm initially', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show delete confirm when handleDelete is called', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-btn'))
|
||||
|
||||
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show details modal initially', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show details modal when handleShowTemplateDetails is called', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
|
||||
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close details modal when onClose is called', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('details-close-btn'))
|
||||
expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct props to details modal', () => {
|
||||
const pipeline = createMockPipeline({ id: 'detail-test-id' })
|
||||
const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
|
||||
expect(screen.getByTestId('details-id')).toHaveTextContent('detail-test-id')
|
||||
expect(screen.getByTestId('details-type')).toHaveTextContent('customized')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Event Handlers Tests
|
||||
* Tests for callback functions and user interactions
|
||||
*/
|
||||
describe('Event Handlers', () => {
|
||||
describe('handleUseTemplate', () => {
|
||||
it('should call getPipelineTemplateInfo when apply template is clicked', async () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call createDataset when pipelineTemplateInfo is not available', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: null })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// createDataset should not be called when pipelineTemplateInfo is null
|
||||
expect(mockCreateDataset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call createDataset with correct yaml_content', async () => {
|
||||
const pipelineResponse = createMockPipelineByIdResponse({ export_data: 'test-yaml-content' })
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: pipelineResponse })
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDataset).toHaveBeenCalledWith(
|
||||
{ yaml_content: 'test-yaml-content' },
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate list, check plugin dependencies, and navigate on success', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
|
||||
options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: 'new-pipeline-id' })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('new-pipeline-id', true)
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-id/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
it('should track event on successful dataset creation', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
|
||||
options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: 'new-pipeline-id' })
|
||||
})
|
||||
const pipeline = createMockPipeline({ id: 'track-test-id', name: 'Track Test Pipeline' })
|
||||
const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('create_datasets_with_pipeline', {
|
||||
template_name: 'Track Test Pipeline',
|
||||
template_id: 'track-test-id',
|
||||
template_type: 'customized',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleCheckPluginDependencies when pipeline_id is not present', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
|
||||
options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: null })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onError callback when createDataset fails', async () => {
|
||||
const onErrorSpy = jest.fn()
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
|
||||
onErrorSpy()
|
||||
options.onError()
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDataset).toHaveBeenCalled()
|
||||
expect(onErrorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should not navigate on error
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleExportDSL', () => {
|
||||
it('should call exportPipelineDSL with pipeline id', async () => {
|
||||
const pipeline = createMockPipeline({ id: 'export-test-id' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('export-dsl-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportTemplateDSL).toHaveBeenCalledWith('export-test-id', expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call exportPipelineDSL when already exporting', async () => {
|
||||
mockIsExporting = true
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('export-dsl-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportTemplateDSL).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should download file on export success', async () => {
|
||||
mockExportTemplateDSL = jest.fn().mockImplementation((_id, options) => {
|
||||
options.onSuccess({ data: 'exported-yaml-content' })
|
||||
})
|
||||
const pipeline = createMockPipeline({ name: 'Export Pipeline' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('export-dsl-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'Export Pipeline.pipeline',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onError callback on export failure', async () => {
|
||||
const onErrorSpy = jest.fn()
|
||||
mockExportTemplateDSL = jest.fn().mockImplementation((_id, options) => {
|
||||
onErrorSpy()
|
||||
options.onError()
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('export-dsl-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportTemplateDSL).toHaveBeenCalled()
|
||||
expect(onErrorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should not download file on error
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDelete', () => {
|
||||
it('should call deletePipeline on confirm', async () => {
|
||||
mockDeleteTemplate = jest.fn().mockImplementation((_id, options) => {
|
||||
options.onSuccess()
|
||||
})
|
||||
const pipeline = createMockPipeline({ id: 'delete-test-id' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-btn'))
|
||||
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
|
||||
|
||||
// Find and click confirm button
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteTemplate).toHaveBeenCalledWith('delete-test-id', expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate customized template list and close confirm on success', async () => {
|
||||
mockDeleteTemplate = jest.fn().mockImplementation((_id, options) => {
|
||||
options.onSuccess()
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-btn'))
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
|
||||
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close delete confirm on cancel', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-btn'))
|
||||
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
|
||||
|
||||
const cancelButton = screen.getByText('common.operation.cancel')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Callback Stability Tests
|
||||
* Tests for useCallback memoization
|
||||
*/
|
||||
describe('Callback Stability', () => {
|
||||
it('should maintain stable handleShowTemplateDetails reference', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('details-close-btn'))
|
||||
rerender(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain stable openEditModal reference', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-modal-btn'))
|
||||
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-close-btn'))
|
||||
rerender(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-modal-btn'))
|
||||
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Component Memoization Tests
|
||||
* Tests for React.memo behavior
|
||||
*/
|
||||
describe('Component Memoization', () => {
|
||||
it('should render correctly after rerender with same props', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
rerender(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when pipeline prop changes', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('Test Pipeline')
|
||||
|
||||
const newPipeline = createMockPipeline({ name: 'Updated Pipeline' })
|
||||
rerender(<TemplateCard {...props} pipeline={newPipeline} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('Updated Pipeline')
|
||||
})
|
||||
|
||||
it('should update when type prop changes', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
rerender(<TemplateCard {...props} type="customized" />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when showMoreOperations prop changes', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { rerender } = render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('actions')).toHaveAttribute('data-show-more', 'true')
|
||||
|
||||
rerender(<TemplateCard {...props} showMoreOperations={false} />)
|
||||
|
||||
expect(screen.getByTestId('actions')).toHaveAttribute('data-show-more', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Edge Cases Tests
|
||||
* Tests for boundary conditions and error handling
|
||||
*/
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty pipeline name', () => {
|
||||
const pipeline = createMockPipeline({ name: '' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
expect(() => render(<TemplateCard {...props} />)).not.toThrow()
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle empty pipeline description', () => {
|
||||
const pipeline = createMockPipeline({ description: '' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
expect(() => render(<TemplateCard {...props} />)).not.toThrow()
|
||||
expect(screen.getByTestId('content-description')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle very long pipeline name', () => {
|
||||
const longName = 'A'.repeat(200)
|
||||
const pipeline = createMockPipeline({ name: longName })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent(longName)
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
const pipeline = createMockPipeline({ name: 'Test <>&"\'Pipeline @#$%' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('Test <>&"\'Pipeline @#$%')
|
||||
})
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
const pipeline = createMockPipeline({ name: '测试管道 🚀 テスト' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-name')).toHaveTextContent('测试管道 🚀 テスト')
|
||||
})
|
||||
|
||||
it('should handle all chunk structure types', () => {
|
||||
const chunkModes = [ChunkingMode.text, ChunkingMode.parentChild, ChunkingMode.qa]
|
||||
|
||||
chunkModes.forEach((mode) => {
|
||||
const pipeline = createMockPipeline({ chunk_structure: mode })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
const { unmount } = render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(mode)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Component Lifecycle Tests
|
||||
* Tests for mount/unmount behavior
|
||||
*/
|
||||
describe('Component Lifecycle', () => {
|
||||
it('should mount without errors', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
expect(() => render(<TemplateCard {...props} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should unmount without errors', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
const { unmount } = render(<TemplateCard {...props} />)
|
||||
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle rapid mount/unmount cycles', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { unmount } = render(<TemplateCard {...props} />)
|
||||
unmount()
|
||||
}
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Modal Integration Tests
|
||||
* Tests for modal interactions and nested callbacks
|
||||
*/
|
||||
describe('Modal Integration', () => {
|
||||
it('should pass correct pipeline to edit modal', () => {
|
||||
const pipeline = createMockPipeline({ id: 'modal-test-id' })
|
||||
const props = { ...createDefaultProps(), pipeline }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-modal-btn'))
|
||||
|
||||
expect(screen.getByTestId('edit-pipeline-id')).toHaveTextContent('modal-test-id')
|
||||
})
|
||||
|
||||
it('should be able to apply template from details modal', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
|
||||
options.onSuccess({ dataset_id: 'new-id', pipeline_id: 'new-pipeline' })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
fireEvent.click(screen.getByTestId('details-apply-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockCreateDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple modals sequentially', () => {
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
// Open edit modal
|
||||
fireEvent.click(screen.getByTestId('edit-modal-btn'))
|
||||
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
|
||||
|
||||
// Close edit modal
|
||||
fireEvent.click(screen.getByTestId('edit-close-btn'))
|
||||
expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Open details modal
|
||||
fireEvent.click(screen.getByTestId('show-details-btn'))
|
||||
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
|
||||
|
||||
// Close details modal
|
||||
fireEvent.click(screen.getByTestId('details-close-btn'))
|
||||
expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Open delete confirm
|
||||
fireEvent.click(screen.getByTestId('delete-btn'))
|
||||
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* API Integration Tests
|
||||
* Tests for service hook interactions
|
||||
*/
|
||||
describe('API Integration', () => {
|
||||
it('should initialize hooks with correct parameters', () => {
|
||||
const pipeline = createMockPipeline({ id: 'hook-test-id' })
|
||||
const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle async operations correctly', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation(async (_req, options) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
options.onSuccess({ dataset_id: 'async-test-id', pipeline_id: 'async-pipeline' })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/async-test-id/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle concurrent API calls gracefully', async () => {
|
||||
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
|
||||
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
|
||||
options.onSuccess({ dataset_id: 'concurrent-id', pipeline_id: 'concurrent-pipeline' })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<TemplateCard {...props} />)
|
||||
|
||||
// Trigger multiple clicks
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
fireEvent.click(screen.getByTestId('apply-template-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -16,6 +16,13 @@ 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,6 +15,13 @@ 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,6 +4,12 @@ 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,6 +2,12 @@ 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,6 +2,12 @@ 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,6 +3,13 @@ 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,
|
||||
|
||||
@ -1,686 +0,0 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import WorkflowOnboardingModal from './index'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
// Mock Modal component
|
||||
jest.mock('@/app/components/base/modal', () => {
|
||||
return function MockModal({
|
||||
isShow,
|
||||
onClose,
|
||||
children,
|
||||
closable,
|
||||
}: any) {
|
||||
if (!isShow)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="modal" role="dialog">
|
||||
{closable && (
|
||||
<button data-testid="modal-close-button" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useDocLink hook
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
// Mock StartNodeSelectionPanel (using real component would be better for integration,
|
||||
// but for this test we'll mock to control behavior)
|
||||
jest.mock('./start-node-selection-panel', () => {
|
||||
return function MockStartNodeSelectionPanel({
|
||||
onSelectUserInput,
|
||||
onSelectTrigger,
|
||||
}: any) {
|
||||
return (
|
||||
<div data-testid="start-node-selection-panel">
|
||||
<button data-testid="select-user-input" onClick={onSelectUserInput}>
|
||||
Select User Input
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-trigger-schedule"
|
||||
onClick={() => onSelectTrigger(BlockEnum.TriggerSchedule)}
|
||||
>
|
||||
Select Trigger Schedule
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-trigger-webhook"
|
||||
onClick={() => onSelectTrigger(BlockEnum.TriggerWebhook, { config: 'test' })}
|
||||
>
|
||||
Select Trigger Webhook
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('WorkflowOnboardingModal', () => {
|
||||
const mockOnClose = jest.fn()
|
||||
const mockOnSelectStartNode = jest.fn()
|
||||
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
onClose: mockOnClose,
|
||||
onSelectStartNode: mockOnSelectStartNode,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to render component
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<WorkflowOnboardingModal {...defaultProps} {...props} />)
|
||||
}
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal when isShow is true', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render modal when isShow is false', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal title', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal description', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert - Check both parts of description (separated by link)
|
||||
const descriptionDiv = container.querySelector('.body-xs-regular.leading-4')
|
||||
expect(descriptionDiv).toBeInTheDocument()
|
||||
expect(descriptionDiv).toHaveTextContent('workflow.onboarding.description')
|
||||
expect(descriptionDiv).toHaveTextContent('workflow.onboarding.aboutStartNode')
|
||||
})
|
||||
|
||||
it('should render learn more link', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const learnMoreLink = screen.getByText('workflow.onboarding.learnMore')
|
||||
expect(learnMoreLink).toBeInTheDocument()
|
||||
expect(learnMoreLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/workflow/node/start')
|
||||
expect(learnMoreLink.closest('a')).toHaveAttribute('target', '_blank')
|
||||
expect(learnMoreLink.closest('a')).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should render StartNodeSelectionPanel', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ESC tip when modal is shown', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ESC tip when modal is hidden', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('workflow.onboarding.escTip.press')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct styling for title', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const title = screen.getByText('workflow.onboarding.title')
|
||||
expect(title).toHaveClass('title-2xl-semi-bold')
|
||||
expect(title).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should have modal close button', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal-close-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should accept isShow prop', () => {
|
||||
// Arrange & Act
|
||||
const { rerender } = renderComponent({ isShow: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept onClose prop', () => {
|
||||
// Arrange
|
||||
const customOnClose = jest.fn()
|
||||
|
||||
// Act
|
||||
renderComponent({ onClose: customOnClose })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept onSelectStartNode prop', () => {
|
||||
// Arrange
|
||||
const customHandler = jest.fn()
|
||||
|
||||
// Act
|
||||
renderComponent({ onSelectStartNode: customHandler })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onClose gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderComponent({ onClose: undefined })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onSelectStartNode gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderComponent({ onSelectStartNode: undefined })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions - Start Node Selection
|
||||
describe('User Interactions - Start Node Selection', () => {
|
||||
it('should call onSelectStartNode with Start block when user input is selected', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const userInputButton = screen.getByTestId('select-user-input')
|
||||
await user.click(userInputButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
|
||||
})
|
||||
|
||||
it('should call onClose after selecting user input', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const userInputButton = screen.getByTestId('select-user-input')
|
||||
await user.click(userInputButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSelectStartNode with trigger type when trigger is selected', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const triggerButton = screen.getByTestId('select-trigger-schedule')
|
||||
await user.click(triggerButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined)
|
||||
})
|
||||
|
||||
it('should call onClose after selecting trigger', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const triggerButton = screen.getByTestId('select-trigger-schedule')
|
||||
await user.click(triggerButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should pass tool config when selecting trigger with config', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const webhookButton = screen.getByTestId('select-trigger-webhook')
|
||||
await user.click(webhookButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' })
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions - Modal Close
|
||||
describe('User Interactions - Modal Close', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const closeButton = screen.getByTestId('modal-close-button')
|
||||
await user.click(closeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onSelectStartNode when closing without selection', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const closeButton = screen.getByTestId('modal-close-button')
|
||||
await user.click(closeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectStartNode).not.toHaveBeenCalled()
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Keyboard Event Handling
|
||||
describe('Keyboard Event Handling', () => {
|
||||
it('should call onClose when ESC key is pressed', () => {
|
||||
// Arrange
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Act
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClose when other keys are pressed', () => {
|
||||
// Arrange
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Act
|
||||
fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'Tab', code: 'Tab' })
|
||||
fireEvent.keyDown(document, { key: 'a', code: 'KeyA' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClose when ESC is pressed but modal is hidden', () => {
|
||||
// Arrange
|
||||
renderComponent({ isShow: false })
|
||||
|
||||
// Act
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clean up event listener on unmount', () => {
|
||||
// Arrange
|
||||
const { unmount } = renderComponent({ isShow: true })
|
||||
|
||||
// Act
|
||||
unmount()
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update event listener when isShow changes', () => {
|
||||
// Arrange
|
||||
const { rerender } = renderComponent({ isShow: true })
|
||||
|
||||
// Act - Press ESC when shown
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Act - Hide modal and clear mock
|
||||
mockOnClose.mockClear()
|
||||
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={false} />)
|
||||
|
||||
// Act - Press ESC when hidden
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multiple ESC key presses', () => {
|
||||
// Arrange
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Act
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid modal show/hide toggling', async () => {
|
||||
// Arrange
|
||||
const { rerender } = renderComponent({ isShow: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={false} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle selecting multiple nodes in sequence', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = renderComponent()
|
||||
|
||||
// Act - Select user input
|
||||
await user.click(screen.getByTestId('select-user-input'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Act - Re-show modal and select trigger
|
||||
mockOnClose.mockClear()
|
||||
mockOnSelectStartNode.mockClear()
|
||||
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
|
||||
|
||||
await user.click(screen.getByTestId('select-trigger-schedule'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined)
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle prop updates correctly', () => {
|
||||
// Arrange
|
||||
const { rerender } = renderComponent({ isShow: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
|
||||
// Act - Update props
|
||||
const newOnClose = jest.fn()
|
||||
const newOnSelectStartNode = jest.fn()
|
||||
rerender(
|
||||
<WorkflowOnboardingModal
|
||||
isShow={true}
|
||||
onClose={newOnClose}
|
||||
onSelectStartNode={newOnSelectStartNode}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - Modal still renders with new props
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle onClose being called multiple times', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId('modal-close-button'))
|
||||
await user.click(screen.getByTestId('modal-close-button'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should maintain modal state when props change', () => {
|
||||
// Arrange
|
||||
const { rerender } = renderComponent({ isShow: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
|
||||
// Act - Change onClose handler
|
||||
const newOnClose = jest.fn()
|
||||
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} onClose={newOnClose} />)
|
||||
|
||||
// Assert - Modal should still be visible
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should have dialog role', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have proper heading hierarchy', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const heading = container.querySelector('h3')
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('workflow.onboarding.title')
|
||||
})
|
||||
|
||||
it('should have external link with proper attributes', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('workflow.onboarding.learnMore').closest('a')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should have keyboard navigation support via ESC key', () => {
|
||||
// Arrange
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Act
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should have visible ESC key hint', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Assert
|
||||
const escKey = screen.getByText('workflow.onboarding.escTip.key')
|
||||
expect(escKey.closest('kbd')).toBeInTheDocument()
|
||||
expect(escKey.closest('kbd')).toHaveClass('system-kbd')
|
||||
})
|
||||
|
||||
it('should have descriptive text for ESC functionality', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have proper text color classes', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const title = screen.getByText('workflow.onboarding.title')
|
||||
expect(title).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should have underlined learn more link', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('workflow.onboarding.learnMore').closest('a')
|
||||
expect(link).toHaveClass('underline')
|
||||
expect(link).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should complete full flow of selecting user input node', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Assert - Initial state
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
|
||||
|
||||
// Act - Select user input
|
||||
await user.click(screen.getByTestId('select-user-input'))
|
||||
|
||||
// Assert - Callbacks called
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should complete full flow of selecting trigger node', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Assert - Initial state
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
|
||||
// Act - Select trigger
|
||||
await user.click(screen.getByTestId('select-trigger-webhook'))
|
||||
|
||||
// Assert - Callbacks called with config
|
||||
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' })
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render all components in correct hierarchy', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert - Modal is the root
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
|
||||
// Assert - Header elements
|
||||
const heading = container.querySelector('h3')
|
||||
expect(heading).toBeInTheDocument()
|
||||
|
||||
// Assert - Description with link
|
||||
expect(screen.getByText('workflow.onboarding.learnMore').closest('a')).toBeInTheDocument()
|
||||
|
||||
// Assert - Selection panel
|
||||
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
|
||||
|
||||
// Assert - ESC tip
|
||||
expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should coordinate between keyboard and click interactions', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Click close button
|
||||
await user.click(screen.getByTestId('modal-close-button'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Act - Clear and try ESC key
|
||||
mockOnClose.mockClear()
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,348 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import StartNodeOption from './start-node-option'
|
||||
|
||||
describe('StartNodeOption', () => {
|
||||
const mockOnClick = jest.fn()
|
||||
const defaultProps = {
|
||||
icon: <div data-testid="test-icon">Icon</div>,
|
||||
title: 'Test Title',
|
||||
description: 'Test description for the option',
|
||||
onClick: mockOnClick,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to render component
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<StartNodeOption {...defaultProps} {...props} />)
|
||||
}
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon correctly', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title correctly', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const title = screen.getByText('Test Title')
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(title).toHaveClass('system-md-semi-bold')
|
||||
expect(title).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should render description correctly', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const description = screen.getByText('Test description for the option')
|
||||
expect(description).toBeInTheDocument()
|
||||
expect(description).toHaveClass('system-xs-regular')
|
||||
expect(description).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should be rendered as a clickable card', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const card = container.querySelector('.cursor-pointer')
|
||||
expect(card).toBeInTheDocument()
|
||||
// Check that it has cursor-pointer class to indicate clickability
|
||||
expect(card).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should render with subtitle when provided', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ subtitle: 'Optional Subtitle' })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Optional Subtitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render subtitle when not provided', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const titleElement = screen.getByText('Test Title').parentElement
|
||||
expect(titleElement).not.toHaveTextContent('Optional Subtitle')
|
||||
})
|
||||
|
||||
it('should render subtitle with correct styling', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ subtitle: 'Subtitle Text' })
|
||||
|
||||
// Assert
|
||||
const subtitle = screen.getByText('Subtitle Text')
|
||||
expect(subtitle).toHaveClass('system-md-regular')
|
||||
expect(subtitle).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should render custom icon component', () => {
|
||||
// Arrange
|
||||
const customIcon = <svg data-testid="custom-svg">Custom</svg>
|
||||
|
||||
// Act
|
||||
renderComponent({ icon: customIcon })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render long title correctly', () => {
|
||||
// Arrange
|
||||
const longTitle = 'This is a very long title that should still render correctly'
|
||||
|
||||
// Act
|
||||
renderComponent({ title: longTitle })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render long description correctly', () => {
|
||||
// Arrange
|
||||
const longDescription = 'This is a very long description that explains the option in great detail and should still render correctly within the component layout'
|
||||
|
||||
// Act
|
||||
renderComponent({ description: longDescription })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with proper layout structure', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test description for the option')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when card is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]')
|
||||
await user.click(card!)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick when icon is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const icon = screen.getByTestId('test-icon')
|
||||
await user.click(icon)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick when title is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const title = screen.getByText('Test Title')
|
||||
await user.click(title)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick when description is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const description = screen.getByText('Test description for the option')
|
||||
await user.click(description)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle multiple rapid clicks', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]')
|
||||
await user.click(card!)
|
||||
await user.click(card!)
|
||||
await user.click(card!)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should not throw error if onClick is undefined', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ onClick: undefined })
|
||||
|
||||
// Act & Assert
|
||||
const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]')
|
||||
await expect(user.click(card!)).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string title', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ title: '' })
|
||||
|
||||
// Assert
|
||||
const titleContainer = screen.getByText('Test description for the option').parentElement?.parentElement
|
||||
expect(titleContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string description', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ description: '' })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined subtitle gracefully', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ subtitle: undefined })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string subtitle', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ subtitle: '' })
|
||||
|
||||
// Assert
|
||||
// Empty subtitle should still render but be empty
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null subtitle', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ subtitle: null })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with subtitle containing special characters', () => {
|
||||
// Arrange
|
||||
const specialSubtitle = '(optional) - [Beta]'
|
||||
|
||||
// Act
|
||||
renderComponent({ subtitle: specialSubtitle })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(specialSubtitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with title and subtitle together', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent({
|
||||
title: 'Main Title',
|
||||
subtitle: 'Secondary Text',
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Main Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Secondary Text')).toBeInTheDocument()
|
||||
|
||||
// Both should be in the same heading element
|
||||
const heading = container.querySelector('h3')
|
||||
expect(heading).toHaveTextContent('Main Title')
|
||||
expect(heading).toHaveTextContent('Secondary Text')
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic heading structure', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const heading = container.querySelector('h3')
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Test Title')
|
||||
})
|
||||
|
||||
it('should have semantic paragraph for description', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const paragraph = container.querySelector('p')
|
||||
expect(paragraph).toBeInTheDocument()
|
||||
expect(paragraph).toHaveTextContent('Test description for the option')
|
||||
})
|
||||
|
||||
it('should have proper cursor style for accessibility', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const card = container.querySelector('.cursor-pointer')
|
||||
expect(card).toBeInTheDocument()
|
||||
expect(card).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// Additional Edge Cases
|
||||
describe('Additional Edge Cases', () => {
|
||||
it('should handle click when onClick handler is missing', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ onClick: undefined })
|
||||
|
||||
// Act & Assert - Should not throw error
|
||||
const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]')
|
||||
await expect(user.click(card!)).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,586 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import StartNodeSelectionPanel from './start-node-selection-panel'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
// Mock NodeSelector component
|
||||
jest.mock('@/app/components/workflow/block-selector', () => {
|
||||
return function MockNodeSelector({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
trigger,
|
||||
}: any) {
|
||||
// trigger is a function that returns a React element
|
||||
const triggerElement = typeof trigger === 'function' ? trigger() : trigger
|
||||
|
||||
return (
|
||||
<div data-testid="node-selector">
|
||||
{triggerElement}
|
||||
{open && (
|
||||
<div data-testid="node-selector-content">
|
||||
<button
|
||||
data-testid="select-schedule"
|
||||
onClick={() => onSelect(BlockEnum.TriggerSchedule)}
|
||||
>
|
||||
Select Schedule
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-webhook"
|
||||
onClick={() => onSelect(BlockEnum.TriggerWebhook)}
|
||||
>
|
||||
Select Webhook
|
||||
</button>
|
||||
<button
|
||||
data-testid="close-selector"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock icons
|
||||
jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({
|
||||
Home: () => <div data-testid="home-icon">Home</div>,
|
||||
TriggerAll: () => <div data-testid="trigger-all-icon">TriggerAll</div>,
|
||||
}))
|
||||
|
||||
describe('StartNodeSelectionPanel', () => {
|
||||
const mockOnSelectUserInput = jest.fn()
|
||||
const mockOnSelectTrigger = jest.fn()
|
||||
|
||||
const defaultProps = {
|
||||
onSelectUserInput: mockOnSelectUserInput,
|
||||
onSelectTrigger: mockOnSelectTrigger,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to render component
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<StartNodeSelectionPanel {...defaultProps} {...props} />)
|
||||
}
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render user input option', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.userInputDescription')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('home-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger option', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trigger-all-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render node selector component', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('node-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct grid layout', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
expect(grid).toHaveClass('grid-cols-2')
|
||||
expect(grid).toHaveClass('gap-4')
|
||||
})
|
||||
|
||||
it('should not show trigger selector initially', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should accept onSelectUserInput prop', () => {
|
||||
// Arrange
|
||||
const customHandler = jest.fn()
|
||||
|
||||
// Act
|
||||
renderComponent({ onSelectUserInput: customHandler })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept onSelectTrigger prop', () => {
|
||||
// Arrange
|
||||
const customHandler = jest.fn()
|
||||
|
||||
// Act
|
||||
renderComponent({ onSelectTrigger: customHandler })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing onSelectUserInput gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderComponent({ onSelectUserInput: undefined })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing onSelectTrigger gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderComponent({ onSelectTrigger: undefined })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions - User Input Option
|
||||
describe('User Interactions - User Input', () => {
|
||||
it('should call onSelectUserInput when user input option is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await user.click(userInputOption)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onSelectTrigger when user input option is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await user.click(userInputOption)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multiple clicks on user input option', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await user.click(userInputOption)
|
||||
await user.click(userInputOption)
|
||||
await user.click(userInputOption)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectUserInput).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions - Trigger Option
|
||||
describe('User Interactions - Trigger', () => {
|
||||
it('should show trigger selector when trigger option is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('node-selector-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onSelectTrigger immediately when trigger option is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSelectTrigger when a trigger is selected from selector', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open trigger selector
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Act - Select a trigger
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-schedule')).toBeInTheDocument()
|
||||
})
|
||||
const scheduleButton = screen.getByTestId('select-schedule')
|
||||
await user.click(scheduleButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined)
|
||||
})
|
||||
|
||||
it('should call onSelectTrigger with correct node type for webhook', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open trigger selector
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Act - Select webhook trigger
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-webhook')).toBeInTheDocument()
|
||||
})
|
||||
const webhookButton = screen.getByTestId('select-webhook')
|
||||
await user.click(webhookButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, undefined)
|
||||
})
|
||||
|
||||
it('should hide trigger selector after selection', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open trigger selector
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Act - Select a trigger
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-schedule')).toBeInTheDocument()
|
||||
})
|
||||
const scheduleButton = screen.getByTestId('select-schedule')
|
||||
await user.click(scheduleButton)
|
||||
|
||||
// Assert - Selector should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass tool config parameter through onSelectTrigger', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open trigger selector
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Act - Select a trigger (our mock doesn't pass toolConfig, but real NodeSelector would)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-schedule')).toBeInTheDocument()
|
||||
})
|
||||
const scheduleButton = screen.getByTestId('select-schedule')
|
||||
await user.click(scheduleButton)
|
||||
|
||||
// Assert - Verify handler was called
|
||||
// In real usage, NodeSelector would pass toolConfig as second parameter
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// State Management
|
||||
describe('State Management', () => {
|
||||
it('should toggle trigger selector visibility', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Assert - Initially hidden
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
|
||||
// Act - Show selector
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Assert - Now visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('node-selector-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Act - Close selector
|
||||
const closeButton = screen.getByTestId('close-selector')
|
||||
await user.click(closeButton)
|
||||
|
||||
// Assert - Hidden again
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain state across user input selections', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Click user input multiple times
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await user.click(userInputOption)
|
||||
await user.click(userInputOption)
|
||||
|
||||
// Assert - Trigger selector should remain hidden
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset trigger selector visibility after selection', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open and select trigger
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-schedule')).toBeInTheDocument()
|
||||
})
|
||||
const scheduleButton = screen.getByTestId('select-schedule')
|
||||
await user.click(scheduleButton)
|
||||
|
||||
// Assert - Selector should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Act - Click trigger option again
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Assert - Selector should open again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('node-selector-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid clicks on trigger option', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
await user.click(triggerOption)
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Assert - Should still be open (last click)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('node-selector-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle selecting different trigger types in sequence', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open and select schedule
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-schedule')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByTestId('select-schedule'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).toHaveBeenNthCalledWith(1, BlockEnum.TriggerSchedule, undefined)
|
||||
|
||||
// Act - Open again and select webhook
|
||||
await user.click(triggerOption)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-webhook')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByTestId('select-webhook'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).toHaveBeenNthCalledWith(2, BlockEnum.TriggerWebhook, undefined)
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not crash with undefined callbacks', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({
|
||||
onSelectUserInput: undefined,
|
||||
onSelectTrigger: undefined,
|
||||
})
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await expect(user.click(userInputOption)).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle opening and closing selector without selection', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open selector
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Act - Close without selecting
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('close-selector')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByTestId('close-selector'))
|
||||
|
||||
// Assert - No selection callback should be called
|
||||
expect(mockOnSelectTrigger).not.toHaveBeenCalled()
|
||||
|
||||
// Assert - Selector should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should have both options visible and accessible', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeVisible()
|
||||
expect(screen.getByText('workflow.onboarding.trigger')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should have descriptive text for both options', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.onboarding.userInputDescription')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have icons for visual identification', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('home-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trigger-all-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain focus after interactions', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await user.click(userInputOption)
|
||||
|
||||
// Assert - Component should still be in document
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should coordinate between both options correctly', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Click user input
|
||||
const userInputOption = screen.getByText('workflow.onboarding.userInputFull')
|
||||
await user.click(userInputOption)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectTrigger).not.toHaveBeenCalled()
|
||||
|
||||
// Act - Click trigger
|
||||
const triggerOption = screen.getByText('workflow.onboarding.trigger')
|
||||
await user.click(triggerOption)
|
||||
|
||||
// Assert - Trigger selector should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('node-selector-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Act - Select trigger
|
||||
await user.click(screen.getByTestId('select-schedule'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render all components in correct hierarchy', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
|
||||
// Both StartNodeOption components should be rendered
|
||||
expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument()
|
||||
|
||||
// NodeSelector should be rendered
|
||||
expect(screen.getByTestId('node-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
324
web/e2e/README.md
Normal file
324
web/e2e/README.md
Normal file
@ -0,0 +1,324 @@
|
||||
# E2E Testing Guide
|
||||
|
||||
This directory contains End-to-End (E2E) tests for the Dify web application using [Playwright](https://playwright.dev/).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies (if not already done)
|
||||
pnpm install
|
||||
|
||||
# Install Playwright browsers
|
||||
pnpm exec playwright install chromium
|
||||
```
|
||||
|
||||
### 2. Configure Environment (Optional)
|
||||
|
||||
Add E2E test configuration to your `web/.env.local` file:
|
||||
|
||||
```env
|
||||
# E2E Test Configuration
|
||||
# Base URL of the frontend (optional, defaults to http://localhost:3000)
|
||||
E2E_BASE_URL=https://test.example.com
|
||||
|
||||
# Skip starting dev server (use existing deployed server)
|
||||
E2E_SKIP_WEB_SERVER=true
|
||||
|
||||
# API URL (optional, defaults to http://localhost:5001/console/api)
|
||||
NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||
|
||||
# Authentication Configuration
|
||||
# Test user credentials
|
||||
NEXT_PUBLIC_E2E_USER_EMAIL=test@example.com
|
||||
NEXT_PUBLIC_E2E_USER_PASSWORD=your-password
|
||||
```
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
Dify supports multiple login methods, but not all are suitable for E2E testing:
|
||||
|
||||
| Method | E2E Support | Configuration |
|
||||
|--------|-------------|---------------|
|
||||
| **Email + Password** | ✅ Recommended | Set `NEXT_PUBLIC_E2E_USER_EMAIL` and `NEXT_PUBLIC_E2E_USER_PASSWORD` |
|
||||
|
||||
#### Email + Password (Default)
|
||||
|
||||
The most reliable method for E2E testing. Simply set the credentials:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_E2E_USER_EMAIL=test@example.com
|
||||
NEXT_PUBLIC_E2E_USER_PASSWORD=your-password
|
||||
```
|
||||
|
||||
### 3. Run Tests
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
pnpm test:e2e
|
||||
|
||||
# Run tests with UI (interactive mode)
|
||||
pnpm test:e2e:ui
|
||||
|
||||
# Run tests with browser visible
|
||||
pnpm test:e2e:headed
|
||||
|
||||
# Run tests in debug mode
|
||||
pnpm test:e2e:debug
|
||||
|
||||
# View test report
|
||||
pnpm test:e2e:report
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── .env.local # Environment config (includes E2E variables)
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
└── e2e/
|
||||
├── fixtures/ # Test fixtures (extended test objects)
|
||||
│ └── index.ts # Main fixtures with page objects
|
||||
├── pages/ # Page Object Models (POM)
|
||||
│ ├── base.page.ts # Base class for all page objects
|
||||
│ ├── signin.page.ts # Sign-in page interactions
|
||||
│ ├── apps.page.ts # Apps listing page interactions
|
||||
│ ├── workflow.page.ts # Workflow editor interactions
|
||||
│ └── index.ts # Page objects export
|
||||
├── tests/ # Test files (*.spec.ts)
|
||||
├── utils/ # Test utilities
|
||||
│ ├── index.ts # Utils export
|
||||
│ ├── test-helpers.ts # Common helper functions
|
||||
│ └── api-helpers.ts # API-level test helpers
|
||||
├── .auth/ # Authentication state (gitignored)
|
||||
├── global.setup.ts # Authentication setup
|
||||
├── global.teardown.ts # Cleanup after tests
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Using Page Objects
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../fixtures'
|
||||
|
||||
test('create a new app', async ({ appsPage }) => {
|
||||
await appsPage.goto()
|
||||
await appsPage.createApp({
|
||||
name: 'My Test App',
|
||||
type: 'chatbot',
|
||||
})
|
||||
await appsPage.expectAppExists('My Test App')
|
||||
})
|
||||
```
|
||||
|
||||
### Using Test Helpers
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../fixtures'
|
||||
import { generateTestId, waitForNetworkIdle } from '../utils/test-helpers'
|
||||
|
||||
test('search functionality', async ({ appsPage }) => {
|
||||
const uniqueName = generateTestId('app')
|
||||
// ... test logic
|
||||
})
|
||||
```
|
||||
|
||||
### Test Data Cleanup
|
||||
|
||||
Always clean up test data to avoid polluting the database:
|
||||
|
||||
```typescript
|
||||
test('create and delete app', async ({ appsPage }) => {
|
||||
const appName = generateTestId('test-app')
|
||||
|
||||
// Create
|
||||
await appsPage.createApp({ name: appName, type: 'chatbot' })
|
||||
|
||||
// Test assertions
|
||||
await appsPage.expectAppExists(appName)
|
||||
|
||||
// Cleanup
|
||||
await appsPage.deleteApp(appName)
|
||||
})
|
||||
```
|
||||
|
||||
### Skipping Authentication
|
||||
|
||||
For tests that need to verify unauthenticated behavior:
|
||||
|
||||
```typescript
|
||||
test.describe('unauthenticated tests', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test('redirects to login', async ({ page }) => {
|
||||
await page.goto('/apps')
|
||||
await expect(page).toHaveURL(/\/signin/)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Page Object Model (POM)
|
||||
|
||||
- Encapsulate page interactions in page objects
|
||||
- Makes tests more readable and maintainable
|
||||
- Changes to selectors only need to be updated in one place
|
||||
|
||||
### 2. Use Meaningful Test Names
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
test('should display error message for invalid email format', ...)
|
||||
|
||||
// Bad
|
||||
test('test1', ...)
|
||||
```
|
||||
|
||||
### 3. Use Data-TestId Attributes
|
||||
|
||||
When adding elements to the application, use `data-testid` attributes:
|
||||
|
||||
```tsx
|
||||
// In React component
|
||||
<button data-testid="create-app-button">Create App</button>
|
||||
|
||||
// In test
|
||||
await page.getByTestId('create-app-button').click()
|
||||
```
|
||||
|
||||
### 4. Generate Unique Test Data
|
||||
|
||||
```typescript
|
||||
import { generateTestId } from '../utils/test-helpers'
|
||||
|
||||
const appName = generateTestId('my-app') // e.g., "my-app-1732567890123-abc123"
|
||||
```
|
||||
|
||||
### 5. Handle Async Operations
|
||||
|
||||
```typescript
|
||||
// Wait for element
|
||||
await expect(element).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/\/apps/)
|
||||
|
||||
// Wait for network
|
||||
await page.waitForLoadState('networkidle')
|
||||
```
|
||||
|
||||
## Creating New Page Objects
|
||||
|
||||
1. Create a new file in `e2e/pages/`:
|
||||
|
||||
```typescript
|
||||
// e2e/pages/my-feature.page.ts
|
||||
import type { Page, Locator } from '@playwright/test'
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
export class MyFeaturePage extends BasePage {
|
||||
readonly myElement: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.myElement = page.getByTestId('my-element')
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return '/my-feature'
|
||||
}
|
||||
|
||||
async doSomething(): Promise<void> {
|
||||
await this.myElement.click()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Export from `e2e/pages/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { MyFeaturePage } from './my-feature.page'
|
||||
```
|
||||
|
||||
3. Add to fixtures in `e2e/fixtures/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { MyFeaturePage } from '../pages/my-feature.page'
|
||||
|
||||
type DifyFixtures = {
|
||||
// ... existing fixtures
|
||||
myFeaturePage: MyFeaturePage
|
||||
}
|
||||
|
||||
export const test = base.extend<DifyFixtures>({
|
||||
// ... existing fixtures
|
||||
myFeaturePage: async ({ page }, use) => {
|
||||
await use(new MyFeaturePage(page))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Visual Debugging
|
||||
|
||||
```bash
|
||||
# Open Playwright UI
|
||||
pnpm test:e2e:ui
|
||||
|
||||
# Run with visible browser
|
||||
pnpm test:e2e:headed
|
||||
|
||||
# Debug mode with inspector
|
||||
pnpm test:e2e:debug
|
||||
```
|
||||
|
||||
### Traces and Screenshots
|
||||
|
||||
Failed tests automatically capture:
|
||||
- Screenshots
|
||||
- Video recordings
|
||||
- Trace files
|
||||
|
||||
View them:
|
||||
```bash
|
||||
pnpm test:e2e:report
|
||||
```
|
||||
|
||||
### Manual Trace Viewing
|
||||
|
||||
```bash
|
||||
pnpm exec playwright show-trace e2e/test-results/path-to-trace.zip
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests timeout waiting for elements
|
||||
|
||||
1. Check if selectors are correct
|
||||
2. Increase timeout: `{ timeout: 30000 }`
|
||||
3. Add explicit waits: `await page.waitForSelector(...)`
|
||||
|
||||
### Authentication issues
|
||||
|
||||
1. Make sure global.setup.ts has completed successfully
|
||||
2. For deployed environments, ensure E2E_BASE_URL matches your cookie domain
|
||||
3. Clear auth state: `rm -rf e2e/.auth/`
|
||||
|
||||
### Flaky tests
|
||||
|
||||
1. Add explicit waits for async operations
|
||||
2. Use `test.slow()` for inherently slow tests
|
||||
3. Add retry logic for unstable operations
|
||||
|
||||
## Resources
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/docs/intro)
|
||||
- [Page Object Model Pattern](https://playwright.dev/docs/pom)
|
||||
- [Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- [Debugging Guide](https://playwright.dev/docs/debug)
|
||||
|
||||
55
web/e2e/fixtures/index.ts
Normal file
55
web/e2e/fixtures/index.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { AppsPage } from '../pages/apps.page'
|
||||
import { SignInPage } from '../pages/signin.page'
|
||||
import { WorkflowPage } from '../pages/workflow.page'
|
||||
|
||||
/**
|
||||
* Extended test fixtures for Dify E2E tests
|
||||
*
|
||||
* This module provides custom fixtures that inject page objects
|
||||
* into tests, making it easier to write maintainable tests.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { test, expect } from '@/e2e/fixtures'
|
||||
*
|
||||
* test('can create new app', async ({ appsPage }) => {
|
||||
* await appsPage.goto()
|
||||
* await appsPage.createApp('My Test App')
|
||||
* await expect(appsPage.appCard('My Test App')).toBeVisible()
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Define custom fixtures type
|
||||
type DifyFixtures = {
|
||||
appsPage: AppsPage
|
||||
signInPage: SignInPage
|
||||
workflowPage: WorkflowPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test object with Dify-specific fixtures
|
||||
*/
|
||||
export const test = base.extend<DifyFixtures>({
|
||||
// Apps page fixture
|
||||
appsPage: async ({ page }, run) => {
|
||||
const appsPage = new AppsPage(page)
|
||||
await run(appsPage)
|
||||
},
|
||||
|
||||
// Sign in page fixture
|
||||
signInPage: async ({ page }, run) => {
|
||||
const signInPage = new SignInPage(page)
|
||||
await run(signInPage)
|
||||
},
|
||||
|
||||
// Workflow page fixture
|
||||
workflowPage: async ({ page }, run) => {
|
||||
const workflowPage = new WorkflowPage(page)
|
||||
await run(workflowPage)
|
||||
},
|
||||
})
|
||||
|
||||
// Re-export expect for convenience
|
||||
export { expect }
|
||||
127
web/e2e/global.setup.ts
Normal file
127
web/e2e/global.setup.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { expect, test as setup } from '@playwright/test'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const authFile = path.join(__dirname, '.auth/user.json')
|
||||
|
||||
/**
|
||||
* Supported authentication methods for E2E tests
|
||||
* - password: Email + Password login (default, recommended)
|
||||
*
|
||||
* OAuth (GitHub/Google) and SSO are not supported in E2E tests
|
||||
* as they require third-party authentication which cannot be reliably automated.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global setup for E2E tests
|
||||
*
|
||||
* This runs before all tests and handles authentication.
|
||||
* The authenticated state is saved and reused across all tests.
|
||||
*
|
||||
* Environment variables:
|
||||
* - NEXT_PUBLIC_E2E_USER_EMAIL: Test user email (required)
|
||||
* - NEXT_PUBLIC_E2E_USER_PASSWORD: Test user password (required for 'password' method)
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const email = process.env.NEXT_PUBLIC_E2E_USER_EMAIL
|
||||
const password = process.env.NEXT_PUBLIC_E2E_USER_PASSWORD
|
||||
|
||||
// Validate required credentials based on auth method
|
||||
if (!email) {
|
||||
console.warn(
|
||||
'⚠️ NEXT_PUBLIC_E2E_USER_EMAIL not set.',
|
||||
'Creating empty auth state. Tests requiring auth will fail.',
|
||||
)
|
||||
await saveEmptyAuthState(page)
|
||||
return
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
console.warn(
|
||||
'⚠️ NEXT_PUBLIC_E2E_USER_PASSWORD not set for password auth method.',
|
||||
'Creating empty auth state. Tests requiring auth will fail.',
|
||||
)
|
||||
await saveEmptyAuthState(page)
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate to login page
|
||||
await page.goto('/signin')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Execute login
|
||||
await loginWithPassword(page, email, password!)
|
||||
|
||||
// Wait for successful redirect to /apps
|
||||
await expect(page).toHaveURL(/\/apps/, { timeout: 30000 })
|
||||
|
||||
// Save authenticated state
|
||||
await page.context().storageState({ path: authFile })
|
||||
console.log('✅ Authentication successful, state saved.')
|
||||
})
|
||||
|
||||
/**
|
||||
* Save empty auth state when credentials are not available
|
||||
*/
|
||||
async function saveEmptyAuthState(page: import('@playwright/test').Page): Promise<void> {
|
||||
const authDir = path.dirname(authFile)
|
||||
if (!fs.existsSync(authDir))
|
||||
fs.mkdirSync(authDir, { recursive: true })
|
||||
await page.context().storageState({ path: authFile })
|
||||
}
|
||||
|
||||
/**
|
||||
* Login using email and password
|
||||
* Based on: web/app/signin/components/mail-and-password-auth.tsx
|
||||
*/
|
||||
async function loginWithPassword(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
console.log('📧 Logging in with email and password...')
|
||||
|
||||
// Fill in login form
|
||||
// Email input has id="email"
|
||||
await page.locator('#email').fill(email)
|
||||
// Password input has id="password"
|
||||
await page.locator('#password').fill(password)
|
||||
|
||||
// Wait for button to be enabled (form validation passes)
|
||||
const signInButton = page.getByRole('button', { name: /sign in/i })
|
||||
await expect(signInButton).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
// Click login button and wait for navigation or API response
|
||||
// The app uses ky library which follows redirects automatically
|
||||
// Some environments may have WAF/CDN that adds extra redirects
|
||||
// So we use a more flexible approach: wait for either URL change or API response
|
||||
const responsePromise = page.waitForResponse(
|
||||
resp => resp.url().includes('login') && resp.request().method() === 'POST',
|
||||
{ timeout: 15000 },
|
||||
).catch(() => null) // Don't fail if we can't catch the response
|
||||
|
||||
await signInButton.click()
|
||||
|
||||
// Try to get the response, but don't fail if we can't
|
||||
const response = await responsePromise
|
||||
if (response) {
|
||||
const status = response.status()
|
||||
console.log(`📡 Login API response status: ${status}`)
|
||||
// 200 = success, 302 = redirect (some WAF/CDN setups)
|
||||
if (status !== 200 && status !== 302) {
|
||||
// Try to get error details
|
||||
try {
|
||||
const body = await response.json()
|
||||
console.error('❌ Login failed:', body)
|
||||
}
|
||||
catch {
|
||||
console.error(`❌ Login failed with status ${status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('⚠️ Could not capture login API response, will verify via URL redirect')
|
||||
}
|
||||
|
||||
console.log('✅ Password login request sent')
|
||||
}
|
||||
214
web/e2e/global.teardown.ts
Normal file
214
web/e2e/global.teardown.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { request, test as teardown } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Global teardown for E2E tests
|
||||
*
|
||||
* This runs after all tests complete.
|
||||
* Cleans up test data created during E2E tests.
|
||||
*
|
||||
* Environment variables:
|
||||
* - NEXT_PUBLIC_API_PREFIX: API URL (default: http://localhost:5001/console/api)
|
||||
*
|
||||
* Based on Dify API:
|
||||
* - GET /apps - list all apps
|
||||
* - DELETE /apps/{id} - delete an app
|
||||
* - GET /datasets - list all datasets
|
||||
* - DELETE /datasets/{id} - delete a dataset
|
||||
*/
|
||||
|
||||
// API base URL with fallback for local development
|
||||
// Ensure baseURL ends with '/' for proper path concatenation
|
||||
const API_BASE_URL = (process.env.NEXT_PUBLIC_API_PREFIX || 'http://localhost:5001/console/api').replace(/\/?$/, '/')
|
||||
|
||||
// Cloudflare Access headers (for protected environments).
|
||||
// Prefer environment variables to avoid hardcoding secrets in repo.
|
||||
const CF_ACCESS_CLIENT_ID = process.env.CF_ACCESS_CLIENT_ID
|
||||
const CF_ACCESS_CLIENT_SECRET = process.env.CF_ACCESS_CLIENT_SECRET
|
||||
|
||||
const cfAccessHeaders: Record<string, string> = {}
|
||||
if (CF_ACCESS_CLIENT_ID && CF_ACCESS_CLIENT_SECRET) {
|
||||
cfAccessHeaders['CF-Access-Client-Id'] = CF_ACCESS_CLIENT_ID
|
||||
cfAccessHeaders['CF-Access-Client-Secret'] = CF_ACCESS_CLIENT_SECRET
|
||||
}
|
||||
|
||||
// Test data prefixes - used to identify test-created data
|
||||
// Should match the prefix used in generateTestId()
|
||||
const TEST_DATA_PREFIXES = ['e2e-', 'test-']
|
||||
|
||||
/**
|
||||
* Check if a name matches test data pattern
|
||||
*/
|
||||
function isTestData(name: string): boolean {
|
||||
return TEST_DATA_PREFIXES.some(prefix => name.toLowerCase().startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single app by ID
|
||||
*/
|
||||
async function deleteApp(
|
||||
context: Awaited<ReturnType<typeof request.newContext>>,
|
||||
app: { id: string, name: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await context.delete(`apps/${app.id}`)
|
||||
return response.ok()
|
||||
}
|
||||
catch {
|
||||
console.warn(` Failed to delete app "${app.name}"`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single dataset by ID
|
||||
*/
|
||||
async function deleteDataset(
|
||||
context: Awaited<ReturnType<typeof request.newContext>>,
|
||||
dataset: { id: string, name: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await context.delete(`datasets/${dataset.id}`)
|
||||
return response.ok()
|
||||
}
|
||||
catch {
|
||||
console.warn(` Failed to delete dataset "${dataset.name}"`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
teardown('cleanup test data', async () => {
|
||||
console.log('🧹 Starting global teardown...')
|
||||
|
||||
const fs = await import('node:fs')
|
||||
const authPath = 'e2e/.auth/user.json'
|
||||
|
||||
// Check if auth state file exists and has cookies
|
||||
if (!fs.existsSync(authPath)) {
|
||||
console.warn('⚠️ Auth state file not found, skipping cleanup')
|
||||
console.log('🧹 Global teardown complete.')
|
||||
return
|
||||
}
|
||||
|
||||
let csrfToken = ''
|
||||
try {
|
||||
const authState = JSON.parse(fs.readFileSync(authPath, 'utf-8'))
|
||||
if (!authState.cookies || authState.cookies.length === 0) {
|
||||
console.warn('⚠️ Auth state is empty (no cookies), skipping cleanup')
|
||||
console.log('🧹 Global teardown complete.')
|
||||
return
|
||||
}
|
||||
// Extract CSRF token from cookies for API requests
|
||||
// Cookie name may be 'csrf_token' or '__Host-csrf_token' depending on environment
|
||||
const csrfCookie = authState.cookies.find((c: { name: string }) =>
|
||||
c.name === 'csrf_token' || c.name === '__Host-csrf_token',
|
||||
)
|
||||
csrfToken = csrfCookie?.value || ''
|
||||
}
|
||||
catch {
|
||||
console.warn('⚠️ Failed to read auth state, skipping cleanup')
|
||||
console.log('🧹 Global teardown complete.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create API request context with auth state and CSRF header
|
||||
const context = await request.newContext({
|
||||
baseURL: API_BASE_URL,
|
||||
storageState: authPath,
|
||||
extraHTTPHeaders: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
...cfAccessHeaders,
|
||||
},
|
||||
})
|
||||
|
||||
// Clean up test apps
|
||||
const appsDeleted = await cleanupTestApps(context)
|
||||
console.log(` 📱 Deleted ${appsDeleted} test apps`)
|
||||
|
||||
// Clean up test datasets
|
||||
const datasetsDeleted = await cleanupTestDatasets(context)
|
||||
console.log(` 📚 Deleted ${datasetsDeleted} test datasets`)
|
||||
|
||||
await context.dispose()
|
||||
}
|
||||
catch (error) {
|
||||
// Don't fail teardown if cleanup fails - just log the error
|
||||
console.warn('⚠️ Teardown cleanup encountered errors:', error)
|
||||
}
|
||||
|
||||
// Clean up auth state file in CI environment for security
|
||||
// In local development, keep it for faster iteration (skip re-login)
|
||||
if (process.env.CI) {
|
||||
try {
|
||||
fs.unlinkSync(authPath)
|
||||
console.log(' 🔐 Auth state file deleted (CI mode)')
|
||||
}
|
||||
catch {
|
||||
// Ignore if file doesn't exist or can't be deleted
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🧹 Global teardown complete.')
|
||||
})
|
||||
|
||||
/**
|
||||
* Clean up test apps
|
||||
* Deletes all apps with names starting with test prefixes
|
||||
*/
|
||||
async function cleanupTestApps(context: Awaited<ReturnType<typeof request.newContext>>): Promise<number> {
|
||||
try {
|
||||
// Fetch all apps - API: GET /apps
|
||||
const response = await context.get('apps', {
|
||||
params: { page: 1, limit: 100 },
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
console.warn(' Failed to fetch apps list:', response.status(), response.url())
|
||||
return 0
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const apps: Array<{ id: string, name: string }> = data.data || []
|
||||
|
||||
// Filter test apps and delete them
|
||||
const testApps = apps.filter(app => isTestData(app.name))
|
||||
const results = await Promise.all(testApps.map(app => deleteApp(context, app)))
|
||||
|
||||
return results.filter(Boolean).length
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(' Error cleaning up apps:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test datasets (knowledge bases)
|
||||
* Deletes all datasets with names starting with test prefixes
|
||||
*/
|
||||
async function cleanupTestDatasets(context: Awaited<ReturnType<typeof request.newContext>>): Promise<number> {
|
||||
try {
|
||||
// Fetch all datasets - API: GET /datasets
|
||||
const response = await context.get('datasets', {
|
||||
params: { page: 1, limit: 100 },
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
console.warn(' Failed to fetch datasets list:', response.status(), response.url())
|
||||
return 0
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const datasets: Array<{ id: string, name: string }> = data.data || []
|
||||
|
||||
// Filter test datasets and delete them
|
||||
const testDatasets = datasets.filter(dataset => isTestData(dataset.name))
|
||||
const results = await Promise.all(testDatasets.map(dataset => deleteDataset(context, dataset)))
|
||||
|
||||
return results.filter(Boolean).length
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(' Error cleaning up datasets:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
243
web/e2e/pages/apps.page.ts
Normal file
243
web/e2e/pages/apps.page.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Apps (Studio) Page Object Model
|
||||
*
|
||||
* Handles interactions with the main apps listing page.
|
||||
* Based on: web/app/components/apps/list.tsx
|
||||
* web/app/components/apps/new-app-card.tsx
|
||||
* web/app/components/apps/app-card.tsx
|
||||
*/
|
||||
export class AppsPage extends BasePage {
|
||||
// Main page elements
|
||||
readonly createFromBlankButton: Locator
|
||||
readonly createFromTemplateButton: Locator
|
||||
readonly importDSLButton: Locator
|
||||
readonly searchInput: Locator
|
||||
readonly appGrid: Locator
|
||||
|
||||
// Create app modal elements (from create-app-modal/index.tsx)
|
||||
readonly createAppModal: Locator
|
||||
readonly appNameInput: Locator
|
||||
readonly appDescriptionInput: Locator
|
||||
readonly createButton: Locator
|
||||
readonly cancelButton: Locator
|
||||
|
||||
// App type selectors in create modal
|
||||
readonly chatbotType: Locator
|
||||
readonly completionType: Locator
|
||||
readonly workflowType: Locator
|
||||
readonly agentType: Locator
|
||||
readonly chatflowType: Locator
|
||||
|
||||
// Delete confirmation
|
||||
readonly deleteConfirmButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
|
||||
// Create app card buttons (from new-app-card.tsx)
|
||||
// t('app.newApp.startFromBlank') = "Create from Blank"
|
||||
this.createFromBlankButton = page.getByRole('button', { name: 'Create from Blank' })
|
||||
// t('app.newApp.startFromTemplate') = "Create from Template"
|
||||
this.createFromTemplateButton = page.getByRole('button', { name: 'Create from Template' })
|
||||
// t('app.importDSL') = "Import DSL file"
|
||||
this.importDSLButton = page.getByRole('button', { name: /Import DSL/i })
|
||||
|
||||
// Search input (from list.tsx)
|
||||
this.searchInput = page.getByPlaceholder(/search/i)
|
||||
|
||||
// App grid container
|
||||
this.appGrid = page.locator('.grid').first()
|
||||
|
||||
// Create app modal
|
||||
this.createAppModal = page.locator('[class*="fullscreen-modal"]').or(page.getByRole('dialog'))
|
||||
|
||||
// App name input - placeholder: t('app.newApp.appNamePlaceholder') = "Give your app a name"
|
||||
this.appNameInput = page.getByPlaceholder('Give your app a name')
|
||||
|
||||
// Description input - placeholder: t('app.newApp.appDescriptionPlaceholder') = "Enter the description of the app"
|
||||
this.appDescriptionInput = page.getByPlaceholder('Enter the description of the app')
|
||||
|
||||
// Create button - t('app.newApp.Create') = "Create"
|
||||
this.createButton = page.getByRole('button', { name: 'Create', exact: true })
|
||||
this.cancelButton = page.getByRole('button', { name: 'Cancel' })
|
||||
|
||||
// App type selectors (from create-app-modal)
|
||||
// These are displayed as clickable cards/buttons
|
||||
this.chatbotType = page.getByText('Chatbot', { exact: true })
|
||||
this.completionType = page.getByText('Completion', { exact: true }).or(page.getByText('Text Generator'))
|
||||
this.workflowType = page.getByText('Workflow', { exact: true })
|
||||
this.agentType = page.getByText('Agent', { exact: true })
|
||||
this.chatflowType = page.getByText('Chatflow', { exact: true })
|
||||
|
||||
// Delete confirmation button
|
||||
this.deleteConfirmButton = page.getByRole('button', { name: /confirm|delete/i }).last()
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return '/apps'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app card by name
|
||||
* App cards use AppIcon and display the app name
|
||||
*/
|
||||
appCard(name: string): Locator {
|
||||
return this.appGrid.locator(`div:has-text("${name}")`).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app card's more menu button (three dots)
|
||||
*/
|
||||
appCardMenu(name: string): Locator {
|
||||
return this.appCard(name).locator('svg[class*="ri-more"]').or(
|
||||
this.appCard(name).locator('button:has(svg)').last(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Create from Blank" button
|
||||
*/
|
||||
async clickCreateFromBlank(): Promise<void> {
|
||||
await this.createFromBlankButton.click()
|
||||
await expect(this.createAppModal).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Create from Template" button
|
||||
*/
|
||||
async clickCreateFromTemplate(): Promise<void> {
|
||||
await this.createFromTemplateButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select app type in create modal
|
||||
*/
|
||||
async selectAppType(type: 'chatbot' | 'completion' | 'workflow' | 'agent' | 'chatflow'): Promise<void> {
|
||||
const typeMap: Record<string, Locator> = {
|
||||
chatbot: this.chatbotType,
|
||||
completion: this.completionType,
|
||||
workflow: this.workflowType,
|
||||
agent: this.agentType,
|
||||
chatflow: this.chatflowType,
|
||||
}
|
||||
await typeMap[type].click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill app name
|
||||
*/
|
||||
async fillAppName(name: string): Promise<void> {
|
||||
await this.appNameInput.fill(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill app description
|
||||
*/
|
||||
async fillAppDescription(description: string): Promise<void> {
|
||||
await this.appDescriptionInput.fill(description)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm app creation
|
||||
*/
|
||||
async confirmCreate(): Promise<void> {
|
||||
await this.createButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new app with full flow
|
||||
*/
|
||||
async createApp(options: {
|
||||
name: string
|
||||
type?: 'chatbot' | 'completion' | 'workflow' | 'agent' | 'chatflow'
|
||||
description?: string
|
||||
}): Promise<void> {
|
||||
const { name, type = 'chatbot', description } = options
|
||||
|
||||
await this.clickCreateFromBlank()
|
||||
await this.selectAppType(type)
|
||||
await this.fillAppName(name)
|
||||
|
||||
if (description)
|
||||
await this.fillAppDescription(description)
|
||||
|
||||
await this.confirmCreate()
|
||||
|
||||
// Wait for navigation to new app or modal to close
|
||||
await this.page.waitForURL(/\/app\//, { timeout: 30000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for an app
|
||||
*/
|
||||
async searchApp(query: string): Promise<void> {
|
||||
await this.searchInput.fill(query)
|
||||
await this.page.waitForTimeout(500) // Debounce
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an app by clicking its card
|
||||
*/
|
||||
async openApp(name: string): Promise<void> {
|
||||
await this.appCard(name).click()
|
||||
await this.waitForNavigation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an app by name
|
||||
*/
|
||||
async deleteApp(name: string): Promise<void> {
|
||||
// Hover on app card to show menu
|
||||
await this.appCard(name).hover()
|
||||
|
||||
// Click more menu (three dots icon)
|
||||
await this.appCardMenu(name).click()
|
||||
|
||||
// Click delete in menu
|
||||
// t('common.operation.delete') = "Delete"
|
||||
await this.page.getByRole('menuitem', { name: 'Delete' })
|
||||
.or(this.page.getByText('Delete').last())
|
||||
.click()
|
||||
|
||||
// Confirm deletion
|
||||
await this.deleteConfirmButton.click()
|
||||
|
||||
// Wait for app to be removed
|
||||
await expect(this.appCard(name)).toBeHidden({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of visible apps
|
||||
*/
|
||||
async getAppCount(): Promise<number> {
|
||||
// Each app card has the app icon and name
|
||||
return this.appGrid.locator('[class*="app-card"], [class*="rounded-xl"]').count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if apps list is empty
|
||||
*/
|
||||
async isEmpty(): Promise<boolean> {
|
||||
// Empty state component is shown when no apps
|
||||
const emptyState = this.page.locator('[class*="empty"]')
|
||||
return emptyState.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify app exists
|
||||
*/
|
||||
async expectAppExists(name: string): Promise<void> {
|
||||
await expect(this.page.getByText(name).first()).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify app does not exist
|
||||
*/
|
||||
async expectAppNotExists(name: string): Promise<void> {
|
||||
await expect(this.page.getByText(name).first()).toBeHidden({ timeout: 10000 })
|
||||
}
|
||||
}
|
||||
144
web/e2e/pages/base.page.ts
Normal file
144
web/e2e/pages/base.page.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Base Page Object Model class
|
||||
*
|
||||
* All page objects should extend this class.
|
||||
* Provides common functionality and patterns for page objects.
|
||||
*/
|
||||
export abstract class BasePage {
|
||||
readonly page: Page
|
||||
|
||||
// Common elements that exist across multiple pages
|
||||
protected readonly loadingSpinner: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
|
||||
// Loading spinner - based on web/app/components/base/loading/index.tsx
|
||||
// Uses SVG with .spin-animation class
|
||||
this.loadingSpinner = page.locator('.spin-animation')
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method - each page must define its URL path
|
||||
*/
|
||||
abstract get path(): string
|
||||
|
||||
/**
|
||||
* Navigate to this page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(this.path)
|
||||
await this.waitForPageLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for page to finish loading
|
||||
*/
|
||||
async waitForPageLoad(): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
// Wait for any loading spinners to disappear
|
||||
if (await this.loadingSpinner.isVisible())
|
||||
await this.loadingSpinner.waitFor({ state: 'hidden', timeout: 30000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if page is currently visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.page.url().includes(this.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for and verify a toast notification
|
||||
* Toast text is in .system-sm-semibold class
|
||||
*/
|
||||
async expectToast(text: string | RegExp): Promise<void> {
|
||||
const toast = this.page.locator('.system-sm-semibold').filter({ hasText: text })
|
||||
await expect(toast).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a successful operation toast
|
||||
* Success toast has bg-toast-success-bg background and RiCheckboxCircleFill icon
|
||||
*/
|
||||
async expectSuccessToast(text?: string | RegExp): Promise<void> {
|
||||
// Success toast contains .text-text-success class (green checkmark icon)
|
||||
const successIndicator = this.page.locator('.text-text-success')
|
||||
await expect(successIndicator).toBeVisible({ timeout: 10000 })
|
||||
|
||||
if (text) {
|
||||
const toastText = this.page.locator('.system-sm-semibold').filter({ hasText: text })
|
||||
await expect(toastText).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an error toast
|
||||
* Error toast has bg-toast-error-bg background and RiErrorWarningFill icon
|
||||
*/
|
||||
async expectErrorToast(text?: string | RegExp): Promise<void> {
|
||||
// Error toast contains .text-text-destructive class (red warning icon)
|
||||
const errorIndicator = this.page.locator('.text-text-destructive')
|
||||
await expect(errorIndicator).toBeVisible({ timeout: 10000 })
|
||||
|
||||
if (text) {
|
||||
const toastText = this.page.locator('.system-sm-semibold').filter({ hasText: text })
|
||||
await expect(toastText).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a warning toast
|
||||
* Warning toast has bg-toast-warning-bg background
|
||||
*/
|
||||
async expectWarningToast(text?: string | RegExp): Promise<void> {
|
||||
const warningIndicator = this.page.locator('.text-text-warning-secondary')
|
||||
await expect(warningIndicator).toBeVisible({ timeout: 10000 })
|
||||
|
||||
if (text) {
|
||||
const toastText = this.page.locator('.system-sm-semibold').filter({ hasText: text })
|
||||
await expect(toastText).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page title
|
||||
*/
|
||||
async getTitle(): Promise<string> {
|
||||
return this.page.title()
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot of the current page
|
||||
*/
|
||||
async screenshot(name: string): Promise<void> {
|
||||
await this.page.screenshot({
|
||||
path: `e2e/test-results/screenshots/${name}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for navigation to complete
|
||||
*/
|
||||
async waitForNavigation(options?: { timeout?: number }): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Press keyboard shortcut
|
||||
*/
|
||||
async pressShortcut(shortcut: string): Promise<void> {
|
||||
await this.page.keyboard.press(shortcut)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element by test id (data-testid attribute)
|
||||
*/
|
||||
getByTestId(testId: string): Locator {
|
||||
return this.page.getByTestId(testId)
|
||||
}
|
||||
}
|
||||
10
web/e2e/pages/index.ts
Normal file
10
web/e2e/pages/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Page Object Models Index
|
||||
*
|
||||
* Export all page objects from a single entry point.
|
||||
*/
|
||||
|
||||
export { BasePage } from './base.page'
|
||||
export { SignInPage } from './signin.page'
|
||||
export { AppsPage } from './apps.page'
|
||||
export { WorkflowPage } from './workflow.page'
|
||||
112
web/e2e/pages/signin.page.ts
Normal file
112
web/e2e/pages/signin.page.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Sign In Page Object Model
|
||||
*
|
||||
* Handles all interactions with the login/sign-in page.
|
||||
* Based on: web/app/signin/components/mail-and-password-auth.tsx
|
||||
*/
|
||||
export class SignInPage extends BasePage {
|
||||
readonly emailInput: Locator
|
||||
readonly passwordInput: Locator
|
||||
readonly signInButton: Locator
|
||||
readonly forgotPasswordLink: Locator
|
||||
readonly errorMessage: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
|
||||
// Selectors based on actual signin page
|
||||
// See: web/app/signin/components/mail-and-password-auth.tsx
|
||||
this.emailInput = page.locator('#email') // id="email"
|
||||
this.passwordInput = page.locator('#password') // id="password"
|
||||
this.signInButton = page.getByRole('button', { name: 'Sign in' }) // t('login.signBtn')
|
||||
this.forgotPasswordLink = page.getByRole('link', { name: /forgot/i })
|
||||
this.errorMessage = page.locator('[class*="toast"]').or(page.getByRole('alert'))
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return '/signin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in email address
|
||||
*/
|
||||
async fillEmail(email: string): Promise<void> {
|
||||
await this.emailInput.fill(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in password
|
||||
*/
|
||||
async fillPassword(password: string): Promise<void> {
|
||||
await this.passwordInput.fill(password)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click sign in button
|
||||
*/
|
||||
async clickSignIn(): Promise<void> {
|
||||
await this.signInButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login flow
|
||||
*/
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
await this.fillEmail(email)
|
||||
await this.fillPassword(password)
|
||||
await this.clickSignIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and wait for redirect to dashboard/apps
|
||||
*/
|
||||
async loginAndWaitForRedirect(email: string, password: string): Promise<void> {
|
||||
await this.login(email, password)
|
||||
// After successful login, Dify redirects to /apps
|
||||
await expect(this.page).toHaveURL(/\/apps/, { timeout: 30000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify invalid credentials error is shown
|
||||
* Error message: t('login.error.invalidEmailOrPassword') = "Invalid email or password."
|
||||
*/
|
||||
async expectInvalidCredentialsError(): Promise<void> {
|
||||
await expect(this.errorMessage.filter({ hasText: /invalid|incorrect|wrong/i }))
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email validation error
|
||||
* Error message: t('login.error.emailInValid') = "Please enter a valid email address"
|
||||
*/
|
||||
async expectEmailValidationError(): Promise<void> {
|
||||
await expect(this.errorMessage.filter({ hasText: /valid email/i }))
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password empty error
|
||||
* Error message: t('login.error.passwordEmpty') = "Password is required"
|
||||
*/
|
||||
async expectPasswordEmptyError(): Promise<void> {
|
||||
await expect(this.errorMessage.filter({ hasText: /password.*required/i }))
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is already logged in (auto-redirected)
|
||||
*/
|
||||
async isRedirectedToApps(timeout = 5000): Promise<boolean> {
|
||||
try {
|
||||
await this.page.waitForURL(/\/apps/, { timeout })
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
353
web/e2e/pages/workflow.page.ts
Normal file
353
web/e2e/pages/workflow.page.ts
Normal file
@ -0,0 +1,353 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Workflow Editor Page Object Model
|
||||
*
|
||||
* Handles interactions with the Dify workflow/canvas editor.
|
||||
* Based on: web/app/components/workflow/
|
||||
*
|
||||
* Key components:
|
||||
* - ReactFlow canvas: web/app/components/workflow/index.tsx
|
||||
* - Run button: web/app/components/workflow/header/run-mode.tsx
|
||||
* - Publish button: web/app/components/workflow/header/index.tsx
|
||||
* - Zoom controls: web/app/components/workflow/operator/zoom-in-out.tsx
|
||||
* - Node panel: web/app/components/workflow/panel/index.tsx
|
||||
* - Block selector: web/app/components/workflow/block-selector/
|
||||
*/
|
||||
export class WorkflowPage extends BasePage {
|
||||
// Canvas elements - ReactFlow based (web/app/components/workflow/index.tsx)
|
||||
readonly canvas: Locator
|
||||
readonly minimap: Locator
|
||||
|
||||
// Header action buttons (web/app/components/workflow/header/)
|
||||
readonly runButton: Locator
|
||||
readonly stopButton: Locator
|
||||
readonly publishButton: Locator
|
||||
readonly undoButton: Locator
|
||||
readonly redoButton: Locator
|
||||
readonly historyButton: Locator
|
||||
readonly checklistButton: Locator
|
||||
|
||||
// Zoom controls (web/app/components/workflow/operator/zoom-in-out.tsx)
|
||||
readonly zoomInButton: Locator
|
||||
readonly zoomOutButton: Locator
|
||||
readonly zoomPercentage: Locator
|
||||
|
||||
// Node panel - appears when node is selected (web/app/components/workflow/panel/)
|
||||
readonly nodeConfigPanel: Locator
|
||||
readonly envPanel: Locator
|
||||
readonly versionHistoryPanel: Locator
|
||||
|
||||
// Debug and preview panel (web/app/components/workflow/panel/debug-and-preview/)
|
||||
readonly debugPreviewPanel: Locator
|
||||
readonly chatInput: Locator
|
||||
|
||||
// Block selector - for adding nodes (web/app/components/workflow/block-selector/)
|
||||
readonly blockSelector: Locator
|
||||
readonly blockSearchInput: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
|
||||
// Canvas - ReactFlow renders with these classes
|
||||
this.canvas = page.locator('.react-flow')
|
||||
this.minimap = page.locator('.react-flow__minimap')
|
||||
|
||||
// Run button - shows "Test Run" text with play icon (run-mode.tsx)
|
||||
// When running, shows "Running" with loading spinner
|
||||
this.runButton = page.locator('.flex.items-center').filter({ hasText: /Test Run|Running|Listening/ }).first()
|
||||
this.stopButton = page.locator('button').filter({ has: page.locator('svg.text-text-accent') }).filter({ hasText: '' }).last()
|
||||
|
||||
// Publish button in header (header/index.tsx)
|
||||
this.publishButton = page.getByRole('button', { name: /Publish|Update/ })
|
||||
|
||||
// Undo/Redo buttons (header/undo-redo.tsx)
|
||||
this.undoButton = page.locator('[class*="undo"]').or(page.getByRole('button', { name: 'Undo' }))
|
||||
this.redoButton = page.locator('[class*="redo"]').or(page.getByRole('button', { name: 'Redo' }))
|
||||
|
||||
// History and checklist buttons (header/run-and-history.tsx)
|
||||
this.historyButton = page.getByRole('button', { name: /history/i })
|
||||
this.checklistButton = page.locator('[class*="checklist"]')
|
||||
|
||||
// Zoom controls at bottom (operator/zoom-in-out.tsx)
|
||||
// Uses RiZoomInLine and RiZoomOutLine icons
|
||||
this.zoomInButton = page.locator('.react-flow').locator('..').locator('button').filter({ has: page.locator('[class*="zoom-in"]') }).first()
|
||||
.or(page.locator('svg[class*="RiZoomInLine"]').locator('..'))
|
||||
this.zoomOutButton = page.locator('.react-flow').locator('..').locator('button').filter({ has: page.locator('[class*="zoom-out"]') }).first()
|
||||
.or(page.locator('svg[class*="RiZoomOutLine"]').locator('..'))
|
||||
this.zoomPercentage = page.locator('.system-sm-medium').filter({ hasText: /%$/ })
|
||||
|
||||
// Node config panel - appears on right when node selected (panel/index.tsx)
|
||||
this.nodeConfigPanel = page.locator('.absolute.bottom-1.right-0.top-14')
|
||||
this.envPanel = page.locator('[class*="env-panel"]')
|
||||
this.versionHistoryPanel = page.locator('[class*="version-history"]')
|
||||
|
||||
// Debug preview panel (panel/debug-and-preview/)
|
||||
this.debugPreviewPanel = page.locator('[class*="debug"], [class*="preview-panel"]')
|
||||
this.chatInput = page.locator('textarea[placeholder*="Enter"], textarea[placeholder*="input"]')
|
||||
|
||||
// Block selector popup (block-selector/)
|
||||
this.blockSelector = page.locator('[class*="block-selector"], [role="dialog"]').filter({ hasText: /LLM|Code|HTTP|IF/ })
|
||||
this.blockSearchInput = page.getByPlaceholder(/search/i)
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
// Dynamic path - will be set when navigating to specific workflow
|
||||
return '/app'
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific workflow app by ID
|
||||
*/
|
||||
async gotoWorkflow(appId: string): Promise<void> {
|
||||
await this.page.goto(`/app/${appId}/workflow`)
|
||||
await this.waitForPageLoad()
|
||||
await this.waitForCanvasReady()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for ReactFlow canvas to be fully loaded
|
||||
*/
|
||||
async waitForCanvasReady(): Promise<void> {
|
||||
await expect(this.canvas).toBeVisible({ timeout: 30000 })
|
||||
// Wait for nodes to render (ReactFlow needs time to initialize)
|
||||
await this.page.waitForSelector('.react-flow__node', { timeout: 30000 })
|
||||
await this.page.waitForTimeout(500) // Allow animation to complete
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node by its displayed title/name
|
||||
* Dify nodes use .react-flow__node class with title text inside
|
||||
*/
|
||||
node(name: string): Locator {
|
||||
return this.canvas.locator('.react-flow__node').filter({ hasText: name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start node (entry point of workflow)
|
||||
*/
|
||||
get startNode(): Locator {
|
||||
return this.canvas.locator('.react-flow__node').filter({ hasText: /Start|开始/ }).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get end node
|
||||
*/
|
||||
get endNode(): Locator {
|
||||
return this.canvas.locator('.react-flow__node').filter({ hasText: /End|结束/ }).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new node by clicking on canvas edge and selecting from block selector
|
||||
* @param nodeType - Node type like 'LLM', 'Code', 'HTTP Request', 'IF/ELSE', etc.
|
||||
*/
|
||||
async addNode(nodeType: string): Promise<void> {
|
||||
// Click the + button on a node's edge to open block selector
|
||||
const addButton = this.canvas.locator('.react-flow__node').first()
|
||||
.locator('[class*="handle"], [class*="add"]')
|
||||
await addButton.click()
|
||||
|
||||
// Wait for block selector to appear
|
||||
await expect(this.blockSelector).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Search for node type if search is available
|
||||
if (await this.blockSearchInput.isVisible())
|
||||
await this.blockSearchInput.fill(nodeType)
|
||||
|
||||
// Click on the node type option
|
||||
await this.blockSelector.getByText(nodeType, { exact: false }).first().click()
|
||||
|
||||
await this.waitForCanvasReady()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node on the canvas (opens config panel on right)
|
||||
*/
|
||||
async selectNode(name: string): Promise<void> {
|
||||
await this.node(name).click()
|
||||
// Config panel should appear
|
||||
await expect(this.nodeConfigPanel).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the currently selected node using keyboard
|
||||
*/
|
||||
async deleteSelectedNode(): Promise<void> {
|
||||
await this.page.keyboard.press('Delete')
|
||||
// Or Backspace
|
||||
// await this.page.keyboard.press('Backspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node by name using context menu
|
||||
*/
|
||||
async deleteNode(name: string): Promise<void> {
|
||||
await this.node(name).click({ button: 'right' })
|
||||
await this.page.getByRole('menuitem', { name: /delete|删除/i }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect two nodes by dragging from source handle to target handle
|
||||
*/
|
||||
async connectNodes(fromNode: string, toNode: string): Promise<void> {
|
||||
// ReactFlow uses data-handlepos for handle positions
|
||||
const sourceHandle = this.node(fromNode).locator('.react-flow__handle-right, [data-handlepos="right"]')
|
||||
const targetHandle = this.node(toNode).locator('.react-flow__handle-left, [data-handlepos="left"]')
|
||||
|
||||
await sourceHandle.dragTo(targetHandle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run/test the workflow (click Test Run button)
|
||||
*/
|
||||
async runWorkflow(): Promise<void> {
|
||||
await this.runButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running workflow
|
||||
*/
|
||||
async stopWorkflow(): Promise<void> {
|
||||
await this.stopButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workflow is currently running
|
||||
*/
|
||||
async isRunning(): Promise<boolean> {
|
||||
const text = await this.runButton.textContent()
|
||||
return text?.includes('Running') || text?.includes('Listening') || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the workflow
|
||||
*/
|
||||
async publishWorkflow(): Promise<void> {
|
||||
await this.publishButton.click()
|
||||
|
||||
// Handle confirmation dialog if it appears
|
||||
const confirmButton = this.page.getByRole('button', { name: /confirm|确认/i })
|
||||
if (await confirmButton.isVisible({ timeout: 2000 }))
|
||||
await confirmButton.click()
|
||||
|
||||
await this.expectSuccessToast()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for workflow run to complete (success or failure)
|
||||
*/
|
||||
async waitForRunComplete(timeout = 60000): Promise<void> {
|
||||
// Wait until the "Running" state ends
|
||||
await expect(async () => {
|
||||
const isStillRunning = await this.isRunning()
|
||||
expect(isStillRunning).toBe(false)
|
||||
}).toPass({ timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify workflow run completed successfully
|
||||
*/
|
||||
async expectRunSuccess(): Promise<void> {
|
||||
await this.waitForRunComplete()
|
||||
// Check for success indicators in the debug panel or toast
|
||||
const successIndicator = this.page.locator(':text("Succeeded"), :text("success"), :text("成功")')
|
||||
await expect(successIndicator).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of nodes on canvas
|
||||
*/
|
||||
async getNodeCount(): Promise<number> {
|
||||
return this.canvas.locator('.react-flow__node').count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a specific node exists on canvas
|
||||
*/
|
||||
async expectNodeExists(name: string): Promise<void> {
|
||||
await expect(this.node(name)).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a specific node does not exist on canvas
|
||||
*/
|
||||
async expectNodeNotExists(name: string): Promise<void> {
|
||||
await expect(this.node(name)).not.toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom in the canvas
|
||||
*/
|
||||
async zoomIn(): Promise<void> {
|
||||
// Use keyboard shortcut Ctrl++
|
||||
await this.page.keyboard.press('Control++')
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom out the canvas
|
||||
*/
|
||||
async zoomOut(): Promise<void> {
|
||||
// Use keyboard shortcut Ctrl+-
|
||||
await this.page.keyboard.press('Control+-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit view to show all nodes (keyboard shortcut Ctrl+1)
|
||||
*/
|
||||
async fitView(): Promise<void> {
|
||||
await this.page.keyboard.press('Control+1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current zoom percentage
|
||||
*/
|
||||
async getZoomPercentage(): Promise<number> {
|
||||
const text = await this.zoomPercentage.textContent()
|
||||
return Number.parseInt(text?.replace('%', '') || '100')
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last action (Ctrl+Z)
|
||||
*/
|
||||
async undo(): Promise<void> {
|
||||
await this.page.keyboard.press('Control+z')
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo last undone action (Ctrl+Shift+Z)
|
||||
*/
|
||||
async redo(): Promise<void> {
|
||||
await this.page.keyboard.press('Control+Shift+z')
|
||||
}
|
||||
|
||||
/**
|
||||
* Open version history panel
|
||||
*/
|
||||
async openVersionHistory(): Promise<void> {
|
||||
await this.historyButton.click()
|
||||
await expect(this.versionHistoryPanel).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate selected node (Ctrl+D)
|
||||
*/
|
||||
async duplicateSelectedNode(): Promise<void> {
|
||||
await this.page.keyboard.press('Control+d')
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy selected node (Ctrl+C)
|
||||
*/
|
||||
async copySelectedNode(): Promise<void> {
|
||||
await this.page.keyboard.press('Control+c')
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste node (Ctrl+V)
|
||||
*/
|
||||
async pasteNode(): Promise<void> {
|
||||
await this.page.keyboard.press('Control+v')
|
||||
}
|
||||
}
|
||||
25
web/e2e/tests/apps.spec.ts
Normal file
25
web/e2e/tests/apps.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
/**
|
||||
* Apps page E2E tests
|
||||
*
|
||||
* These tests verify the apps listing and creation functionality.
|
||||
*/
|
||||
|
||||
test.describe('Apps Page', () => {
|
||||
test('should display apps page after authentication', async ({ page }) => {
|
||||
// Navigate to apps page
|
||||
await page.goto('/apps')
|
||||
|
||||
// Verify we're on the apps page (not redirected to signin)
|
||||
await expect(page).toHaveURL(/\/apps/)
|
||||
|
||||
// Wait for the page to fully load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Take a screenshot for debugging
|
||||
await page.screenshot({ path: 'e2e/test-results/apps-page.png' })
|
||||
|
||||
console.log('✅ Apps page loaded successfully')
|
||||
})
|
||||
})
|
||||
165
web/e2e/utils/api-helpers.ts
Normal file
165
web/e2e/utils/api-helpers.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* API helper utilities for test setup and cleanup
|
||||
*
|
||||
* Use these helpers to set up test data via API before tests run,
|
||||
* or to clean up data after tests complete.
|
||||
*
|
||||
* Environment variables:
|
||||
* - NEXT_PUBLIC_API_PREFIX: API URL (default: http://localhost:5001/console/api)
|
||||
*
|
||||
* Based on Dify API configuration:
|
||||
* @see web/config/index.ts - API_PREFIX
|
||||
* @see web/types/app.ts - AppModeEnum
|
||||
*/
|
||||
|
||||
// API base URL with fallback for local development
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_PREFIX || 'http://localhost:5001/console/api'
|
||||
|
||||
/**
|
||||
* Dify App mode types
|
||||
* @see web/types/app.ts - AppModeEnum
|
||||
*/
|
||||
export type AppMode = 'chat' | 'completion' | 'workflow'
|
||||
|
||||
/**
|
||||
* Create a new app via API
|
||||
*
|
||||
* @param request - Playwright API request context
|
||||
* @param data - App data
|
||||
* @param data.name - App name
|
||||
* @param data.mode - App mode: chat (Chatbot), completion (Text Generator),
|
||||
* workflow, advanced-chat (Chatflow), agent-chat (Agent)
|
||||
* @param data.description - Optional description
|
||||
* @param data.icon - Optional icon
|
||||
* @param data.iconBackground - Optional icon background color
|
||||
*/
|
||||
export async function createAppViaApi(
|
||||
request: APIRequestContext,
|
||||
data: {
|
||||
name: string
|
||||
mode: AppMode
|
||||
description?: string
|
||||
icon?: string
|
||||
iconBackground?: string
|
||||
},
|
||||
): Promise<{ id: string, name: string }> {
|
||||
const response = await request.post(`${API_BASE_URL}/apps`, {
|
||||
data: {
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
description: data.description || '',
|
||||
icon: data.icon || 'default',
|
||||
icon_background: data.iconBackground || '#FFFFFF',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to create app: ${error}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an app via API
|
||||
*/
|
||||
export async function deleteAppViaApi(
|
||||
request: APIRequestContext,
|
||||
appId: string,
|
||||
): Promise<void> {
|
||||
const response = await request.delete(`${API_BASE_URL}/apps/${appId}`)
|
||||
|
||||
if (!response.ok() && response.status() !== 404) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to delete app: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dataset/knowledge base via API
|
||||
*/
|
||||
export async function createDatasetViaApi(
|
||||
request: APIRequestContext,
|
||||
data: {
|
||||
name: string
|
||||
description?: string
|
||||
},
|
||||
): Promise<{ id: string, name: string }> {
|
||||
const response = await request.post(`${API_BASE_URL}/datasets`, {
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to create dataset: ${error}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a dataset via API
|
||||
*/
|
||||
export async function deleteDatasetViaApi(
|
||||
request: APIRequestContext,
|
||||
datasetId: string,
|
||||
): Promise<void> {
|
||||
const response = await request.delete(`${API_BASE_URL}/datasets/${datasetId}`)
|
||||
|
||||
if (!response.ok() && response.status() !== 404) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to delete dataset: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info via API
|
||||
*/
|
||||
export async function getCurrentUserViaApi(
|
||||
request: APIRequestContext,
|
||||
): Promise<{ id: string, email: string, name: string }> {
|
||||
const response = await request.get(`${API_BASE_URL}/account/profile`)
|
||||
|
||||
if (!response.ok()) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to get user info: ${error}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup helper - delete all test apps by name pattern
|
||||
*/
|
||||
export async function cleanupTestApps(
|
||||
request: APIRequestContext,
|
||||
namePattern: RegExp,
|
||||
): Promise<number> {
|
||||
const response = await request.get(`${API_BASE_URL}/apps`)
|
||||
|
||||
if (!response.ok())
|
||||
return 0
|
||||
|
||||
const { data: apps } = await response.json() as { data: Array<{ id: string, name: string }> }
|
||||
|
||||
const testApps = apps.filter(app => namePattern.test(app.name))
|
||||
let deletedCount = 0
|
||||
|
||||
for (const app of testApps) {
|
||||
try {
|
||||
await deleteAppViaApi(request, app.id)
|
||||
deletedCount++
|
||||
}
|
||||
catch {
|
||||
// Ignore deletion errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
}
|
||||
8
web/e2e/utils/index.ts
Normal file
8
web/e2e/utils/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Test Utilities Index
|
||||
*
|
||||
* Export all utility functions from a single entry point.
|
||||
*/
|
||||
|
||||
export * from './test-helpers'
|
||||
export * from './api-helpers'
|
||||
174
web/e2e/utils/test-helpers.ts
Normal file
174
web/e2e/utils/test-helpers.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Common test helper utilities for E2E tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wait for network to be idle with a custom timeout
|
||||
*/
|
||||
export async function waitForNetworkIdle(page: Page, timeout = 5000): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element to be stable (not moving/resizing)
|
||||
*/
|
||||
export async function waitForStable(locator: Locator, timeout = 5000): Promise<void> {
|
||||
await locator.waitFor({ state: 'visible', timeout })
|
||||
// Additional wait for animations to complete
|
||||
await locator.evaluate(el => new Promise<void>((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
// Observer callback - intentionally empty, just watching for changes
|
||||
})
|
||||
observer.observe(el, { attributes: true, subtree: true })
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve()
|
||||
}, 100)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely click an element with retry logic
|
||||
*/
|
||||
export async function safeClick(
|
||||
locator: Locator,
|
||||
options?: { timeout?: number, force?: boolean },
|
||||
): Promise<void> {
|
||||
const { timeout = 10000, force = false } = options || {}
|
||||
await locator.waitFor({ state: 'visible', timeout })
|
||||
await locator.click({ force, timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill input with clear first
|
||||
*/
|
||||
export async function fillInput(
|
||||
locator: Locator,
|
||||
value: string,
|
||||
options?: { clear?: boolean },
|
||||
): Promise<void> {
|
||||
const { clear = true } = options || {}
|
||||
if (clear)
|
||||
await locator.clear()
|
||||
|
||||
await locator.fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select option from dropdown/select element
|
||||
*/
|
||||
export async function selectOption(
|
||||
trigger: Locator,
|
||||
optionText: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await trigger.click()
|
||||
await page.getByRole('option', { name: optionText }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for toast notification and verify its content
|
||||
*
|
||||
* Based on Dify toast implementation:
|
||||
* @see web/app/components/base/toast/index.tsx
|
||||
*
|
||||
* Toast structure:
|
||||
* - Container: .fixed.z-[9999] with rounded-xl
|
||||
* - Type background classes: bg-toast-success-bg, bg-toast-error-bg, etc.
|
||||
* - Type icon classes: text-text-success, text-text-destructive, etc.
|
||||
*/
|
||||
export async function waitForToast(
|
||||
page: Page,
|
||||
expectedText: string | RegExp,
|
||||
type?: 'success' | 'error' | 'warning' | 'info',
|
||||
): Promise<Locator> {
|
||||
// Dify toast uses fixed positioning with z-[9999]
|
||||
const toastContainer = page.locator('.fixed.z-\\[9999\\]')
|
||||
|
||||
// Filter by type if specified
|
||||
let toast: Locator
|
||||
if (type) {
|
||||
// Each type has specific background class
|
||||
const typeClassMap: Record<string, string> = {
|
||||
success: '.bg-toast-success-bg',
|
||||
error: '.bg-toast-error-bg',
|
||||
warning: '.bg-toast-warning-bg',
|
||||
info: '.bg-toast-info-bg',
|
||||
}
|
||||
toast = toastContainer.filter({ has: page.locator(typeClassMap[type]) })
|
||||
.filter({ hasText: expectedText })
|
||||
}
|
||||
else {
|
||||
toast = toastContainer.filter({ hasText: expectedText })
|
||||
}
|
||||
|
||||
await toast.waitFor({ state: 'visible', timeout: 10000 })
|
||||
return toast
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss any visible modals
|
||||
*/
|
||||
export async function dismissModal(page: Page): Promise<void> {
|
||||
const modal = page.locator('[role="dialog"]')
|
||||
if (await modal.isVisible()) {
|
||||
// Try clicking close button or backdrop
|
||||
const closeButton = modal.locator('button[aria-label*="close"], button:has-text("Cancel")')
|
||||
if (await closeButton.isVisible())
|
||||
await closeButton.click()
|
||||
else
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await modal.waitFor({ state: 'hidden', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique test identifier
|
||||
*/
|
||||
export function generateTestId(prefix = 'test'): string {
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
return `${prefix}-${timestamp}-${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot with a descriptive name
|
||||
*/
|
||||
export async function takeDebugScreenshot(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
await page.screenshot({
|
||||
path: `e2e/test-results/debug-${name}-${timestamp}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an action with exponential backoff
|
||||
*/
|
||||
export async function retryAction<T>(
|
||||
action: () => Promise<T>,
|
||||
options?: { maxAttempts?: number, baseDelay?: number },
|
||||
): Promise<T> {
|
||||
const { maxAttempts = 3, baseDelay = 1000 } = options || {}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await action()
|
||||
}
|
||||
catch (error) {
|
||||
if (attempt === maxAttempts)
|
||||
throw error
|
||||
|
||||
const delay = baseDelay * 2 ** (attempt - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unreachable')
|
||||
}
|
||||
@ -40,6 +40,11 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"analyze-component": "node testing/analyze-component.js",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@ -158,6 +163,7 @@
|
||||
"@next/bundle-analyzer": "15.5.9",
|
||||
"@next/eslint-plugin-next": "15.5.9",
|
||||
"@next/mdx": "15.5.9",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@rgrove/parse-xml": "^4.2.0",
|
||||
"@storybook/addon-docs": "9.1.13",
|
||||
"@storybook/addon-links": "9.1.13",
|
||||
@ -191,6 +197,7 @@
|
||||
"bing-translate-api": "^4.1.0",
|
||||
"code-inspector-plugin": "1.2.9",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-oxlint": "^1.23.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user