Compare commits

..

5 Commits

Author SHA1 Message Date
47724ec764 feat: enhance E2E testing setup with Cloudflare Access support and improved authentication handling
- Added support for Cloudflare Access headers in Playwright configuration and teardown.
- Updated global setup for E2E tests to validate authentication credentials and handle login more robustly.
- Enhanced README with authentication configuration details and supported methods.
- Updated Playwright reporter configuration to include JSON output for test results.
2025-12-16 14:15:09 +08:00
3863894072 Merge remote-tracking branch 'origin/main' into feat/e2e-testing 2025-12-16 10:04:19 +08:00
cf20e9fd38 Merge remote-tracking branch 'origin/main' into feat/e2e-testing 2025-12-11 15:47:59 +08:00
a8a0f2c900 Merge remote-tracking branch 'origin/main' into feat/e2e-testing 2025-12-10 14:44:51 +08:00
7b968c6c2e feat: add Playwright E2E testing framework and initial test setup
- Introduced Playwright for end-to-end testing, including configuration in .
- Created global setup and teardown scripts for authentication and cleanup.
- Added page object models for key application pages (Apps, SignIn, Workflow).
- Implemented utility functions for API interactions and common test helpers.
- Updated  to exclude Playwright test results and auth files.
- Added initial E2E tests for the Apps page.
- Updated  with new test scripts for E2E testing.
2025-12-09 18:14:57 +08:00
103 changed files with 2451 additions and 13984 deletions

View File

@ -76,7 +76,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
- [ ] `jest.clearAllMocks()` in `beforeEach` (not `afterEach`)
- [ ] Shared mock state reset in `beforeEach`
- [ ] i18n 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ from core.workflow.errors import WorkflowNodeRunFailedError
from core.workflow.graph import Graph
from core.workflow.graph_engine import GraphEngine
from core.workflow.graph_engine.command_channels import InMemoryChannel
from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer, 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

View File

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

View File

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

View File

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

View File

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

View File

@ -184,7 +184,7 @@ def timezone(timezone_string):
def convert_datetime_to_date(field, target_timezone: str = ":tz"):
if dify_config.DB_TYPE == "postgresql":
return f"DATE(DATE_TRUNC('day', {field} AT TIME ZONE 'UTC' AT TIME ZONE {target_timezone}))"
elif dify_config.DB_TYPE 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}")

View File

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

View File

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

View File

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

View File

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

@ -8,6 +8,13 @@
# testing
/coverage
# playwright e2e
/e2e/.auth/
/e2e/test-results/
/playwright-report/
/blob-report/
/test-results/
# next.js
/.next/
/out/

View File

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

View File

@ -1 +0,0 @@
AGENTS.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -401,6 +401,7 @@ function AppCard({
/>
<CustomizeModal
isShow={showCustomizeModal}
linkUrl=""
onClose={() => setShowCustomizeModal(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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