From eca26a9b9b28f3d85f0a2c69c231a1c26bd4d8a3 Mon Sep 17 00:00:00 2001 From: heyszt <270985384@qq.com> Date: Tue, 27 Jan 2026 15:30:21 +0800 Subject: [PATCH 1/4] feat: Enhances OpenTelemetry node parsers (#30706) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/workflow/graph_engine/layers/base.py | 13 +- .../graph_engine/layers/node_parsers.py | 61 ------- .../graph_engine/layers/observability.py | 15 +- api/core/workflow/graph_engine/worker.py | 17 +- api/core/workflow/graph_events/__init__.py | 2 + api/core/workflow/graph_events/node.py | 23 +++ api/extensions/otel/parser/__init__.py | 20 +++ api/extensions/otel/parser/base.py | 117 +++++++++++++ api/extensions/otel/parser/llm.py | 155 ++++++++++++++++++ api/extensions/otel/parser/retrieval.py | 105 ++++++++++++ api/extensions/otel/parser/tool.py | 47 ++++++ api/extensions/otel/semconv/__init__.py | 11 +- api/extensions/otel/semconv/gen_ai.py | 34 ++++ .../workflow/graph_engine/layers/conftest.py | 35 ++++ .../graph_engine/layers/test_observability.py | 101 +++++++++++- 15 files changed, 675 insertions(+), 81 deletions(-) delete mode 100644 api/core/workflow/graph_engine/layers/node_parsers.py create mode 100644 api/extensions/otel/parser/__init__.py create mode 100644 api/extensions/otel/parser/base.py create mode 100644 api/extensions/otel/parser/llm.py create mode 100644 api/extensions/otel/parser/retrieval.py create mode 100644 api/extensions/otel/parser/tool.py diff --git a/api/core/workflow/graph_engine/layers/base.py b/api/core/workflow/graph_engine/layers/base.py index 89293b9b30..ff4a483aed 100644 --- a/api/core/workflow/graph_engine/layers/base.py +++ b/api/core/workflow/graph_engine/layers/base.py @@ -8,7 +8,7 @@ intercept and respond to GraphEngine events. 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.graph_events import GraphEngineEvent, GraphNodeEventBase from core.workflow.nodes.base.node import Node from core.workflow.runtime import ReadOnlyGraphRuntimeState @@ -98,7 +98,7 @@ class GraphEngineLayer(ABC): """ pass - def on_node_run_start(self, node: Node) -> None: # noqa: B027 + def on_node_run_start(self, node: Node) -> None: """ Called immediately before a node begins execution. @@ -109,9 +109,11 @@ class GraphEngineLayer(ABC): Args: node: The node instance about to be executed """ - pass + return - def on_node_run_end(self, node: Node, error: Exception | None) -> None: # noqa: B027 + def on_node_run_end( + self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None + ) -> None: """ Called after a node finishes execution. @@ -121,5 +123,6 @@ class GraphEngineLayer(ABC): Args: node: The node instance that just finished execution error: Exception instance if the node failed, otherwise None + result_event: The final result event from node execution (succeeded/failed/paused), if any """ - pass + return diff --git a/api/core/workflow/graph_engine/layers/node_parsers.py b/api/core/workflow/graph_engine/layers/node_parsers.py deleted file mode 100644 index b6bac794df..0000000000 --- a/api/core/workflow/graph_engine/layers/node_parsers.py +++ /dev/null @@ -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)) diff --git a/api/core/workflow/graph_engine/layers/observability.py b/api/core/workflow/graph_engine/layers/observability.py index a674816884..94839c8ae3 100644 --- a/api/core/workflow/graph_engine/layers/observability.py +++ b/api/core/workflow/graph_engine/layers/observability.py @@ -18,12 +18,15 @@ 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 ( +from core.workflow.graph_events import GraphNodeEventBase +from core.workflow.nodes.base.node import Node +from extensions.otel.parser import ( DefaultNodeOTelParser, + LLMNodeOTelParser, NodeOTelParser, + RetrievalNodeOTelParser, ToolNodeOTelParser, ) -from core.workflow.nodes.base.node import Node from extensions.otel.runtime import is_instrument_flag_enabled logger = logging.getLogger(__name__) @@ -72,6 +75,8 @@ class ObservabilityLayer(GraphEngineLayer): """Initialize parser registry for node types.""" self._parsers = { NodeType.TOOL: ToolNodeOTelParser(), + NodeType.LLM: LLMNodeOTelParser(), + NodeType.KNOWLEDGE_RETRIEVAL: RetrievalNodeOTelParser(), } def _get_parser(self, node: Node) -> NodeOTelParser: @@ -119,7 +124,9 @@ class ObservabilityLayer(GraphEngineLayer): 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: + def on_node_run_end( + self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None + ) -> None: """ Called when a node finishes execution. @@ -139,7 +146,7 @@ class ObservabilityLayer(GraphEngineLayer): span = node_context.span parser = self._get_parser(node) try: - parser.parse(node=node, span=span, error=error) + parser.parse(node=node, span=span, error=error, result_event=result_event) span.end() finally: token = node_context.token diff --git a/api/core/workflow/graph_engine/worker.py b/api/core/workflow/graph_engine/worker.py index 6c69ea5df0..512df6ff86 100644 --- a/api/core/workflow/graph_engine/worker.py +++ b/api/core/workflow/graph_engine/worker.py @@ -17,7 +17,7 @@ from typing_extensions import override from core.workflow.context import IExecutionContext 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.graph_events import GraphNodeEventBase, NodeRunFailedEvent, is_node_result_event from core.workflow.nodes.base.node import Node from .ready_queue import ReadyQueue @@ -131,6 +131,7 @@ class Worker(threading.Thread): node.ensure_execution_id() error: Exception | None = None + result_event: GraphNodeEventBase | None = None # Execute the node with preserved context if execution context is provided if self._execution_context is not None: @@ -140,22 +141,26 @@ class Worker(threading.Thread): node_events = node.run() for event in node_events: self._event_queue.put(event) + if is_node_result_event(event): + result_event = event except Exception as exc: error = exc raise finally: - self._invoke_node_run_end_hooks(node, error) + self._invoke_node_run_end_hooks(node, error, result_event) else: self._invoke_node_run_start_hooks(node) try: node_events = node.run() for event in node_events: self._event_queue.put(event) + if is_node_result_event(event): + result_event = event except Exception as exc: error = exc raise finally: - self._invoke_node_run_end_hooks(node, error) + self._invoke_node_run_end_hooks(node, error, result_event) def _invoke_node_run_start_hooks(self, node: Node) -> None: """Invoke on_node_run_start hooks for all layers.""" @@ -166,11 +171,13 @@ class Worker(threading.Thread): # Silently ignore layer errors to prevent disrupting node execution continue - def _invoke_node_run_end_hooks(self, node: Node, error: Exception | None) -> None: + def _invoke_node_run_end_hooks( + self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None + ) -> None: """Invoke on_node_run_end hooks for all layers.""" for layer in self._layers: try: - layer.on_node_run_end(node, error) + layer.on_node_run_end(node, error, result_event) except Exception: # Silently ignore layer errors to prevent disrupting node execution continue diff --git a/api/core/workflow/graph_events/__init__.py b/api/core/workflow/graph_events/__init__.py index 7a5edbb331..2b6ee4ec1c 100644 --- a/api/core/workflow/graph_events/__init__.py +++ b/api/core/workflow/graph_events/__init__.py @@ -44,6 +44,7 @@ from .node import ( NodeRunStartedEvent, NodeRunStreamChunkEvent, NodeRunSucceededEvent, + is_node_result_event, ) __all__ = [ @@ -73,4 +74,5 @@ __all__ = [ "NodeRunStartedEvent", "NodeRunStreamChunkEvent", "NodeRunSucceededEvent", + "is_node_result_event", ] diff --git a/api/core/workflow/graph_events/node.py b/api/core/workflow/graph_events/node.py index f225798d41..4d0108e77b 100644 --- a/api/core/workflow/graph_events/node.py +++ b/api/core/workflow/graph_events/node.py @@ -56,3 +56,26 @@ class NodeRunRetryEvent(NodeRunStartedEvent): class NodeRunPauseRequestedEvent(GraphNodeEventBase): reason: PauseReason = Field(..., description="pause reason") + + +def is_node_result_event(event: GraphNodeEventBase) -> bool: + """ + Check if an event is a final result event from node execution. + + A result event indicates the completion of a node execution and contains + runtime information such as inputs, outputs, or error details. + + Args: + event: The event to check + + Returns: + True if the event is a node result event (succeeded/failed/paused), False otherwise + """ + return isinstance( + event, + ( + NodeRunSucceededEvent, + NodeRunFailedEvent, + NodeRunPauseRequestedEvent, + ), + ) diff --git a/api/extensions/otel/parser/__init__.py b/api/extensions/otel/parser/__init__.py new file mode 100644 index 0000000000..164db7c275 --- /dev/null +++ b/api/extensions/otel/parser/__init__.py @@ -0,0 +1,20 @@ +""" +OpenTelemetry node parsers for workflow nodes. + +This module provides parsers that extract node-specific metadata and set +OpenTelemetry span attributes according to semantic conventions. +""" + +from extensions.otel.parser.base import DefaultNodeOTelParser, NodeOTelParser, safe_json_dumps +from extensions.otel.parser.llm import LLMNodeOTelParser +from extensions.otel.parser.retrieval import RetrievalNodeOTelParser +from extensions.otel.parser.tool import ToolNodeOTelParser + +__all__ = [ + "DefaultNodeOTelParser", + "LLMNodeOTelParser", + "NodeOTelParser", + "RetrievalNodeOTelParser", + "ToolNodeOTelParser", + "safe_json_dumps", +] diff --git a/api/extensions/otel/parser/base.py b/api/extensions/otel/parser/base.py new file mode 100644 index 0000000000..f4db26e840 --- /dev/null +++ b/api/extensions/otel/parser/base.py @@ -0,0 +1,117 @@ +""" +Base parser interface and utilities for OpenTelemetry node parsers. +""" + +import json +from typing import Any, Protocol + +from opentelemetry.trace import Span +from opentelemetry.trace.status import Status, StatusCode +from pydantic import BaseModel + +from core.file.models import File +from core.variables import Segment +from core.workflow.enums import NodeType +from core.workflow.graph_events import GraphNodeEventBase +from core.workflow.nodes.base.node import Node +from extensions.otel.semconv.gen_ai import ChainAttributes, GenAIAttributes + + +def safe_json_dumps(obj: Any, ensure_ascii: bool = False) -> str: + """ + Safely serialize objects to JSON, handling non-serializable types. + + Handles: + - Segment types (ArrayFileSegment, FileSegment, etc.) - converts to their value + - File objects - converts to dict using to_dict() + - BaseModel objects - converts using model_dump() + - Other types - falls back to str() representation + + Args: + obj: Object to serialize + ensure_ascii: Whether to ensure ASCII encoding + + Returns: + JSON string representation of the object + """ + + def _convert_value(value: Any) -> Any: + """Recursively convert non-serializable values.""" + if value is None: + return None + if isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Segment): + # Convert Segment to its underlying value + return _convert_value(value.value) + if isinstance(value, File): + # Convert File to dict + return value.to_dict() + if isinstance(value, BaseModel): + # Convert Pydantic model to dict + return _convert_value(value.model_dump(mode="json")) + if isinstance(value, dict): + return {k: _convert_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_convert_value(item) for item in value] + # Fallback to string representation for unknown types + return str(value) + + try: + converted = _convert_value(obj) + return json.dumps(converted, ensure_ascii=ensure_ascii) + except (TypeError, ValueError) as e: + # If conversion still fails, return error message as string + return json.dumps( + {"error": f"Failed to serialize: {type(obj).__name__}", "message": str(e)}, ensure_ascii=ensure_ascii + ) + + +class NodeOTelParser(Protocol): + """Parser interface for node-specific OpenTelemetry enrichment.""" + + def parse( + self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None + ) -> None: ... + + +class DefaultNodeOTelParser: + """Fallback parser used when no node-specific parser is registered.""" + + def parse( + self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = 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) + + span.set_attribute(GenAIAttributes.FRAMEWORK, "dify") + + node_type = getattr(node, "node_type", None) + if isinstance(node_type, NodeType): + if node_type == NodeType.LLM: + span.set_attribute(GenAIAttributes.SPAN_KIND, "LLM") + elif node_type == NodeType.KNOWLEDGE_RETRIEVAL: + span.set_attribute(GenAIAttributes.SPAN_KIND, "RETRIEVER") + elif node_type == NodeType.TOOL: + span.set_attribute(GenAIAttributes.SPAN_KIND, "TOOL") + else: + span.set_attribute(GenAIAttributes.SPAN_KIND, "TASK") + else: + span.set_attribute(GenAIAttributes.SPAN_KIND, "TASK") + + # Extract inputs and outputs from result_event + if result_event and result_event.node_run_result: + node_run_result = result_event.node_run_result + if node_run_result.inputs: + span.set_attribute(ChainAttributes.INPUT_VALUE, safe_json_dumps(node_run_result.inputs)) + if node_run_result.outputs: + span.set_attribute(ChainAttributes.OUTPUT_VALUE, safe_json_dumps(node_run_result.outputs)) + + if error: + span.record_exception(error) + span.set_status(Status(StatusCode.ERROR, str(error))) + else: + span.set_status(Status(StatusCode.OK)) diff --git a/api/extensions/otel/parser/llm.py b/api/extensions/otel/parser/llm.py new file mode 100644 index 0000000000..8556974080 --- /dev/null +++ b/api/extensions/otel/parser/llm.py @@ -0,0 +1,155 @@ +""" +Parser for LLM nodes that captures LLM-specific metadata. +""" + +import logging +from collections.abc import Mapping +from typing import Any + +from opentelemetry.trace import Span + +from core.workflow.graph_events import GraphNodeEventBase +from core.workflow.nodes.base.node import Node +from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps +from extensions.otel.semconv.gen_ai import LLMAttributes + +logger = logging.getLogger(__name__) + + +def _format_input_messages(process_data: Mapping[str, Any]) -> str: + """ + Format input messages from process_data for LLM spans. + + Args: + process_data: Process data containing prompts + + Returns: + JSON string of formatted input messages + """ + try: + if not isinstance(process_data, dict): + return safe_json_dumps([]) + + prompts = process_data.get("prompts", []) + if not prompts: + return safe_json_dumps([]) + + valid_roles = {"system", "user", "assistant", "tool"} + input_messages = [] + for prompt in prompts: + if not isinstance(prompt, dict): + continue + + role = prompt.get("role", "") + text = prompt.get("text", "") + + if not role or role not in valid_roles: + continue + + if text: + message = {"role": role, "parts": [{"type": "text", "content": text}]} + input_messages.append(message) + + return safe_json_dumps(input_messages) + except Exception as e: + logger.warning("Failed to format input messages: %s", e, exc_info=True) + return safe_json_dumps([]) + + +def _format_output_messages(outputs: Mapping[str, Any]) -> str: + """ + Format output messages from outputs for LLM spans. + + Args: + outputs: Output data containing text and finish_reason + + Returns: + JSON string of formatted output messages + """ + try: + if not isinstance(outputs, dict): + return safe_json_dumps([]) + + text = outputs.get("text", "") + finish_reason = outputs.get("finish_reason", "") + + if not text: + return safe_json_dumps([]) + + valid_finish_reasons = {"stop", "length", "content_filter", "tool_call", "error"} + if finish_reason not in valid_finish_reasons: + finish_reason = "stop" + + output_message = { + "role": "assistant", + "parts": [{"type": "text", "content": text}], + "finish_reason": finish_reason, + } + + return safe_json_dumps([output_message]) + except Exception as e: + logger.warning("Failed to format output messages: %s", e, exc_info=True) + return safe_json_dumps([]) + + +class LLMNodeOTelParser: + """Parser for LLM nodes that captures LLM-specific metadata.""" + + def __init__(self) -> None: + self._delegate = DefaultNodeOTelParser() + + def parse( + self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None + ) -> None: + self._delegate.parse(node=node, span=span, error=error, result_event=result_event) + + if not result_event or not result_event.node_run_result: + return + + node_run_result = result_event.node_run_result + process_data = node_run_result.process_data or {} + outputs = node_run_result.outputs or {} + + # Extract usage data (from process_data or outputs) + usage_data = process_data.get("usage") or outputs.get("usage") or {} + + # Model and provider information + model_name = process_data.get("model_name") or "" + model_provider = process_data.get("model_provider") or "" + + if model_name: + span.set_attribute(LLMAttributes.REQUEST_MODEL, model_name) + if model_provider: + span.set_attribute(LLMAttributes.PROVIDER_NAME, model_provider) + + # Token usage + if usage_data: + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) + total_tokens = usage_data.get("total_tokens", 0) + + span.set_attribute(LLMAttributes.USAGE_INPUT_TOKENS, prompt_tokens) + span.set_attribute(LLMAttributes.USAGE_OUTPUT_TOKENS, completion_tokens) + span.set_attribute(LLMAttributes.USAGE_TOTAL_TOKENS, total_tokens) + + # Prompts and completion + prompts = process_data.get("prompts", []) + if prompts: + prompts_json = safe_json_dumps(prompts) + span.set_attribute(LLMAttributes.PROMPT, prompts_json) + + text_output = str(outputs.get("text", "")) + if text_output: + span.set_attribute(LLMAttributes.COMPLETION, text_output) + + # Finish reason + finish_reason = outputs.get("finish_reason") or "" + if finish_reason: + span.set_attribute(LLMAttributes.RESPONSE_FINISH_REASON, finish_reason) + + # Structured input/output messages + gen_ai_input_message = _format_input_messages(process_data) + gen_ai_output_message = _format_output_messages(outputs) + + span.set_attribute(LLMAttributes.INPUT_MESSAGE, gen_ai_input_message) + span.set_attribute(LLMAttributes.OUTPUT_MESSAGE, gen_ai_output_message) diff --git a/api/extensions/otel/parser/retrieval.py b/api/extensions/otel/parser/retrieval.py new file mode 100644 index 0000000000..fc151af691 --- /dev/null +++ b/api/extensions/otel/parser/retrieval.py @@ -0,0 +1,105 @@ +""" +Parser for knowledge retrieval nodes that captures retrieval-specific metadata. +""" + +import logging +from collections.abc import Sequence +from typing import Any + +from opentelemetry.trace import Span + +from core.variables import Segment +from core.workflow.graph_events import GraphNodeEventBase +from core.workflow.nodes.base.node import Node +from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps +from extensions.otel.semconv.gen_ai import RetrieverAttributes + +logger = logging.getLogger(__name__) + + +def _format_retrieval_documents(retrieval_documents: list[Any]) -> list: + """ + Format retrieval documents for semantic conventions. + + Args: + retrieval_documents: List of retrieval document dictionaries + + Returns: + List of formatted semantic documents + """ + try: + if not isinstance(retrieval_documents, list): + return [] + + semantic_documents = [] + for doc in retrieval_documents: + if not isinstance(doc, dict): + continue + + metadata = doc.get("metadata", {}) + content = doc.get("content", "") + title = doc.get("title", "") + score = metadata.get("score", 0.0) + document_id = metadata.get("document_id", "") + + semantic_metadata = {} + if title: + semantic_metadata["title"] = title + if metadata.get("source"): + semantic_metadata["source"] = metadata["source"] + elif metadata.get("_source"): + semantic_metadata["source"] = metadata["_source"] + if metadata.get("doc_metadata"): + doc_metadata = metadata["doc_metadata"] + if isinstance(doc_metadata, dict): + semantic_metadata.update(doc_metadata) + + semantic_doc = { + "document": {"content": content, "metadata": semantic_metadata, "score": score, "id": document_id} + } + semantic_documents.append(semantic_doc) + + return semantic_documents + except Exception as e: + logger.warning("Failed to format retrieval documents: %s", e, exc_info=True) + return [] + + +class RetrievalNodeOTelParser: + """Parser for knowledge retrieval nodes that captures retrieval-specific metadata.""" + + def __init__(self) -> None: + self._delegate = DefaultNodeOTelParser() + + def parse( + self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None + ) -> None: + self._delegate.parse(node=node, span=span, error=error, result_event=result_event) + + if not result_event or not result_event.node_run_result: + return + + node_run_result = result_event.node_run_result + inputs = node_run_result.inputs or {} + outputs = node_run_result.outputs or {} + + # Extract query from inputs + query = str(inputs.get("query", "")) if inputs else "" + if query: + span.set_attribute(RetrieverAttributes.QUERY, query) + + # Extract and format retrieval documents from outputs + result_value = outputs.get("result") if outputs else None + retrieval_documents: list[Any] = [] + if result_value: + value_to_check = result_value + if isinstance(result_value, Segment): + value_to_check = result_value.value + + if isinstance(value_to_check, (list, Sequence)): + retrieval_documents = list(value_to_check) + + if retrieval_documents: + semantic_retrieval_documents = _format_retrieval_documents(retrieval_documents) + semantic_retrieval_documents_json = safe_json_dumps(semantic_retrieval_documents) + span.set_attribute(RetrieverAttributes.DOCUMENT, semantic_retrieval_documents_json) diff --git a/api/extensions/otel/parser/tool.py b/api/extensions/otel/parser/tool.py new file mode 100644 index 0000000000..b99180722b --- /dev/null +++ b/api/extensions/otel/parser/tool.py @@ -0,0 +1,47 @@ +""" +Parser for tool nodes that captures tool-specific metadata. +""" + +from opentelemetry.trace import Span + +from core.workflow.enums import WorkflowNodeExecutionMetadataKey +from core.workflow.graph_events import GraphNodeEventBase +from core.workflow.nodes.base.node import Node +from core.workflow.nodes.tool.entities import ToolNodeData +from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps +from extensions.otel.semconv.gen_ai import ToolAttributes + + +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, result_event: GraphNodeEventBase | None = None + ) -> None: + self._delegate.parse(node=node, span=span, error=error, result_event=result_event) + + tool_data = getattr(node, "_node_data", None) + if not isinstance(tool_data, ToolNodeData): + return + + span.set_attribute(ToolAttributes.TOOL_NAME, node.title) + span.set_attribute(ToolAttributes.TOOL_TYPE, tool_data.provider_type.value) + + # Extract tool info from metadata (consistent with aliyun_trace) + tool_info = {} + if result_event and result_event.node_run_result: + node_run_result = result_event.node_run_result + if node_run_result.metadata: + tool_info = node_run_result.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {}) + + if tool_info: + span.set_attribute(ToolAttributes.TOOL_DESCRIPTION, safe_json_dumps(tool_info)) + + if result_event and result_event.node_run_result and result_event.node_run_result.inputs: + span.set_attribute(ToolAttributes.TOOL_CALL_ARGUMENTS, safe_json_dumps(result_event.node_run_result.inputs)) + + if result_event and result_event.node_run_result and result_event.node_run_result.outputs: + span.set_attribute(ToolAttributes.TOOL_CALL_RESULT, safe_json_dumps(result_event.node_run_result.outputs)) diff --git a/api/extensions/otel/semconv/__init__.py b/api/extensions/otel/semconv/__init__.py index dc79dee222..0db3075815 100644 --- a/api/extensions/otel/semconv/__init__.py +++ b/api/extensions/otel/semconv/__init__.py @@ -1,6 +1,13 @@ """Semantic convention shortcuts for Dify-specific spans.""" from .dify import DifySpanAttributes -from .gen_ai import GenAIAttributes +from .gen_ai import ChainAttributes, GenAIAttributes, LLMAttributes, RetrieverAttributes, ToolAttributes -__all__ = ["DifySpanAttributes", "GenAIAttributes"] +__all__ = [ + "ChainAttributes", + "DifySpanAttributes", + "GenAIAttributes", + "LLMAttributes", + "RetrieverAttributes", + "ToolAttributes", +] diff --git a/api/extensions/otel/semconv/gen_ai.py b/api/extensions/otel/semconv/gen_ai.py index 83c52ed34f..88c2058c06 100644 --- a/api/extensions/otel/semconv/gen_ai.py +++ b/api/extensions/otel/semconv/gen_ai.py @@ -62,3 +62,37 @@ class ToolAttributes: TOOL_CALL_RESULT = "gen_ai.tool.call.result" """Tool invocation result.""" + + +class LLMAttributes: + """LLM operation attribute keys.""" + + REQUEST_MODEL = "gen_ai.request.model" + """Model identifier.""" + + PROVIDER_NAME = "gen_ai.provider.name" + """Provider name.""" + + USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" + """Number of input tokens.""" + + USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + """Number of output tokens.""" + + USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" + """Total number of tokens.""" + + PROMPT = "gen_ai.prompt" + """Prompt text.""" + + COMPLETION = "gen_ai.completion" + """Completion text.""" + + RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason" + """Finish reason for the response.""" + + INPUT_MESSAGE = "gen_ai.input.messages" + """Input messages in structured format.""" + + OUTPUT_MESSAGE = "gen_ai.output.messages" + """Output messages in structured format.""" diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py index b18a3369e9..51da3b7d73 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py @@ -99,3 +99,38 @@ 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 + + +@pytest.fixture +def mock_retrieval_node(): + """Create a mock Knowledge Retrieval Node.""" + node = MagicMock() + node.id = "test-retrieval-node-id" + node.title = "Retrieval Node" + node.execution_id = "test-retrieval-execution-id" + node.node_type = NodeType.KNOWLEDGE_RETRIEVAL + return node + + +@pytest.fixture +def mock_result_event(): + """Create a mock result event with NodeRunResult.""" + from datetime import datetime + + from core.workflow.graph_events.node import NodeRunSucceededEvent + from core.workflow.node_events.base import NodeRunResult + + node_run_result = NodeRunResult( + inputs={"query": "test query"}, + outputs={"result": [{"content": "test content", "metadata": {}}]}, + process_data={}, + metadata={}, + ) + + return NodeRunSucceededEvent( + id="test-execution-id", + node_id="test-node-id", + node_type=NodeType.LLM, + start_at=datetime.now(), + node_run_result=node_run_result, + ) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py index 458cf2cc67..8cc080fe94 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py @@ -4,7 +4,8 @@ Tests for ObservabilityLayer. Test coverage: - Initialization and enable/disable logic - Node span lifecycle (start, end, error handling) -- Parser integration (default and tool-specific) +- Parser integration (default, tool, LLM, and retrieval parsers) +- Result event parameter extraction (inputs/outputs) - Graph lifecycle management - Disabled mode behavior """ @@ -134,9 +135,101 @@ class TestObservabilityLayerParserIntegration: 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 + assert attrs["gen_ai.tool.name"] == mock_tool_node.title + assert attrs["gen_ai.tool.type"] == mock_tool_node._node_data.provider_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_llm_parser_used_for_llm_node( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node, mock_result_event + ): + """Test that LLM parser is used for LLM nodes and extracts LLM-specific attributes.""" + from core.workflow.node_events.base import NodeRunResult + + mock_result_event.node_run_result = NodeRunResult( + inputs={}, + outputs={"text": "test completion", "finish_reason": "stop"}, + process_data={ + "model_name": "gpt-4", + "model_provider": "openai", + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + "prompts": [{"role": "user", "text": "test prompt"}], + }, + metadata={}, + ) + + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_llm_node) + layer.on_node_run_end(mock_llm_node, None, mock_result_event) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = spans[0].attributes + assert attrs["node.id"] == mock_llm_node.id + assert attrs["gen_ai.request.model"] == "gpt-4" + assert attrs["gen_ai.provider.name"] == "openai" + assert attrs["gen_ai.usage.input_tokens"] == 10 + assert attrs["gen_ai.usage.output_tokens"] == 20 + assert attrs["gen_ai.usage.total_tokens"] == 30 + assert attrs["gen_ai.completion"] == "test completion" + assert attrs["gen_ai.response.finish_reason"] == "stop" + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_retrieval_parser_used_for_retrieval_node( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_retrieval_node, mock_result_event + ): + """Test that retrieval parser is used for retrieval nodes and extracts retrieval-specific attributes.""" + from core.workflow.node_events.base import NodeRunResult + + mock_result_event.node_run_result = NodeRunResult( + inputs={"query": "test query"}, + outputs={"result": [{"content": "test content", "metadata": {"score": 0.9, "document_id": "doc1"}}]}, + process_data={}, + metadata={}, + ) + + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_retrieval_node) + layer.on_node_run_end(mock_retrieval_node, None, mock_result_event) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = spans[0].attributes + assert attrs["node.id"] == mock_retrieval_node.id + assert attrs["retrieval.query"] == "test query" + assert "retrieval.document" in attrs + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_result_event_extracts_inputs_and_outputs( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node, mock_result_event + ): + """Test that result_event parameter allows parsers to extract inputs and outputs.""" + from core.workflow.node_events.base import NodeRunResult + + mock_result_event.node_run_result = NodeRunResult( + inputs={"input_key": "input_value"}, + outputs={"output_key": "output_value"}, + process_data={}, + metadata={}, + ) + + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_start_node) + layer.on_node_run_end(mock_start_node, None, mock_result_event) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = spans[0].attributes + assert "input.value" in attrs + assert "output.value" in attrs class TestObservabilityLayerGraphLifecycle: From c8abe1c306577e51980e691c2ca2fe868dfe5f47 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 27 Jan 2026 15:43:27 +0800 Subject: [PATCH 2/4] test: add tests for dataset document detail (#31274) Co-authored-by: CodingOnStar Co-authored-by: CodingOnStar --- .../base/input-with-copy/index.spec.tsx | 68 +- .../common/check-rerank-model.spec.ts | 426 ++++++++ .../common/chunking-mode-label.spec.tsx | 61 ++ .../datasets/common/credential-icon.spec.tsx | 136 +++ .../common/document-file-icon.spec.tsx | 115 +++ .../auto-disabled-document.spec.tsx | 166 ++++ .../index-failed.spec.tsx | 280 ++++++ .../status-with-action.spec.tsx | 175 ++++ .../datasets/common/image-list/index.spec.tsx | 252 +++++ .../datasets/common/image-list/more.spec.tsx | 144 +++ .../common/image-previewer/index.spec.tsx | 525 ++++++++++ .../image-uploader/hooks/use-upload.spec.tsx | 922 ++++++++++++++++++ .../image-input.spec.tsx | 107 ++ .../image-item.spec.tsx | 198 ++++ .../image-uploader-in-chunk/index.spec.tsx | 167 ++++ .../image-input.spec.tsx | 125 +++ .../image-item.spec.tsx | 149 +++ .../index.spec.tsx | 238 +++++ .../common/image-uploader/store.spec.tsx | 305 ++++++ .../common/image-uploader/utils.spec.ts | 310 ++++++ .../retrieval-param-config/index.spec.tsx | 323 ++++++ .../dsl-confirm-modal.spec.tsx | 154 +++ .../create-from-dsl-modal/header.spec.tsx | 93 ++ .../create-from-dsl-modal/tab/index.spec.tsx | 121 +++ .../create-from-dsl-modal/tab/item.spec.tsx | 112 +++ .../create-from-dsl-modal/uploader.spec.tsx | 205 ++++ .../create-from-pipeline/footer.spec.tsx | 224 +++++ .../create-from-pipeline/header.spec.tsx | 71 ++ .../create-from-pipeline/index.spec.tsx | 101 ++ .../list/built-in-pipeline-list.spec.tsx | 276 ++++++ .../list/create-card.spec.tsx | 190 ++++ .../list/customized-list.spec.tsx | 151 +++ .../create-from-pipeline/list/index.spec.tsx | 70 ++ .../list/template-card/actions.spec.tsx | 154 +++ .../list/template-card/content.spec.tsx | 199 ++++ .../details/chunk-structure-card.spec.tsx | 182 ++++ .../list/template-card/details/hooks.spec.tsx | 138 +++ .../list/template-card/details/index.spec.tsx | 360 +++++++ .../template-card/edit-pipeline-info.spec.tsx | 665 +++++++++++++ .../list/template-card/index.spec.tsx | 722 ++++++++++++++ .../list/template-card/operations.spec.tsx | 144 +++ .../create/website/base/url-input.spec.tsx | 407 ++++++++ .../create/website/firecrawl/index.spec.tsx | 701 +++++++++++++ .../create/website/firecrawl/options.spec.tsx | 405 ++++++++ .../create/website/jina-reader/index.spec.tsx | 89 +- .../components/documents-header.spec.tsx | 214 ++++ .../components/empty-element.spec.tsx | 95 ++ .../documents/components/icons.spec.tsx | 81 ++ .../documents/components/operations.spec.tsx | 381 ++++++++ .../components/rename-modal.spec.tsx | 183 ++++ .../steps/preview-panel.spec.tsx | 279 ++++++ .../steps/step-one-content.spec.tsx | 413 ++++++++ .../steps/step-three-content.spec.tsx | 97 ++ .../steps/step-two-content.spec.tsx | 136 +++ .../batch-modal/csv-downloader.spec.tsx | 243 +++++ .../detail/batch-modal/csv-uploader.spec.tsx | 485 +++++++++ .../detail/batch-modal/index.spec.tsx | 232 +++++ .../completed/child-segment-detail.spec.tsx | 330 +++++++ .../completed/child-segment-list.spec.tsx | 693 ++++++------- .../completed/common/action-buttons.spec.tsx | 523 ++++++++++ .../completed/common/add-another.spec.tsx | 194 ++++ .../completed/common/batch-action.spec.tsx | 277 ++++++ .../completed/common/chunk-content.spec.tsx | 317 ++++++ .../detail/completed/common/dot.spec.tsx | 60 ++ .../detail/completed/common/empty.spec.tsx | 214 ++-- .../common/full-screen-drawer.spec.tsx | 262 +++++ .../detail/completed/common/keywords.spec.tsx | 317 ++++++ .../common/regeneration-modal.spec.tsx | 327 +++++++ .../common/segment-index-tag.spec.tsx | 215 ++++ .../detail/completed/common/tag.spec.tsx | 151 +++ .../detail/completed/display-toggle.spec.tsx | 130 +++ .../completed/new-child-segment.spec.tsx | 507 ++++++++++ .../segment-card/chunk-content.spec.tsx | 270 +++++ .../detail/completed/segment-detail.spec.tsx | 679 +++++++++++++ .../detail/completed/segment-list.spec.tsx | 442 +++++++++ .../skeleton/full-doc-list-skeleton.spec.tsx | 123 ++- .../skeleton/general-list-skeleton.spec.tsx | 195 ++++ .../skeleton/paragraph-list-skeleton.spec.tsx | 151 +++ .../parent-chunk-card-skeleton.spec.tsx | 132 +++ .../detail/completed/status-item.spec.tsx | 118 +++ .../documents/detail/document-title.spec.tsx | 169 ++++ .../datasets/documents/detail/index.tsx | 4 +- .../documents/detail/metadata/index.spec.tsx | 545 +++++++++++ .../documents/detail/new-segment.spec.tsx | 503 ++++++++++ .../detail/segment-add/index.spec.tsx | 351 +++++++ .../settings/document-settings.spec.tsx | 374 +++++++ .../documents/detail/settings/index.spec.tsx | 143 +++ .../pipeline-settings/left-header.spec.tsx | 154 +++ .../process-documents/actions.spec.tsx | 158 +++ .../datasets/documents/index.spec.tsx | 720 ++++++++++++++ .../extra-info/api-access/index.spec.tsx | 792 +++++++++++++++ .../extra-info/service-api/index.spec.tsx | 772 +++++++++++++++ web/app/components/signin/countdown.spec.tsx | 191 ++++ .../config-credentials.spec.tsx | 490 +++++++++- .../index.spec.tsx | 536 ++++++++-- .../test-api.spec.tsx | 309 +++++- .../components/tools/labels/filter.spec.tsx | 329 +++++++ .../components/tools/labels/selector.spec.tsx | 319 ++++++ web/app/components/tools/mcp/index.spec.tsx | 344 +++++++ .../provider/custom-create-card.spec.tsx | 328 +++++++ .../components/tools/provider/empty.spec.tsx | 179 ++++ .../tools/provider/tool-item.spec.tsx | 279 ++++++ .../workflow-tool/method-selector.spec.tsx | 317 ++++++ web/eslint-suppressions.json | 5 - web/vitest.setup.ts | 8 + 105 files changed, 28225 insertions(+), 686 deletions(-) create mode 100644 web/app/components/datasets/common/check-rerank-model.spec.ts create mode 100644 web/app/components/datasets/common/chunking-mode-label.spec.tsx create mode 100644 web/app/components/datasets/common/credential-icon.spec.tsx create mode 100644 web/app/components/datasets/common/document-file-icon.spec.tsx create mode 100644 web/app/components/datasets/common/document-status-with-action/auto-disabled-document.spec.tsx create mode 100644 web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx create mode 100644 web/app/components/datasets/common/document-status-with-action/status-with-action.spec.tsx create mode 100644 web/app/components/datasets/common/image-list/index.spec.tsx create mode 100644 web/app/components/datasets/common/image-list/more.spec.tsx create mode 100644 web/app/components/datasets/common/image-previewer/index.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/hooks/use-upload.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/store.spec.tsx create mode 100644 web/app/components/datasets/common/image-uploader/utils.spec.ts create mode 100644 web/app/components/datasets/common/retrieval-param-config/index.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/footer.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/header.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/index.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/create-card.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/customized-list.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/index.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/actions.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/content.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/details/hooks.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/details/index.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx create mode 100644 web/app/components/datasets/create-from-pipeline/list/template-card/operations.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/url-input.spec.tsx create mode 100644 web/app/components/datasets/create/website/firecrawl/index.spec.tsx create mode 100644 web/app/components/datasets/create/website/firecrawl/options.spec.tsx create mode 100644 web/app/components/datasets/documents/components/documents-header.spec.tsx create mode 100644 web/app/components/datasets/documents/components/empty-element.spec.tsx create mode 100644 web/app/components/datasets/documents/components/icons.spec.tsx create mode 100644 web/app/components/datasets/documents/components/operations.spec.tsx create mode 100644 web/app/components/datasets/documents/components/rename-modal.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/status-item.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/document-title.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/new-segment.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/segment-add/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx create mode 100644 web/app/components/datasets/documents/index.spec.tsx create mode 100644 web/app/components/datasets/extra-info/api-access/index.spec.tsx create mode 100644 web/app/components/datasets/extra-info/service-api/index.spec.tsx create mode 100644 web/app/components/signin/countdown.spec.tsx create mode 100644 web/app/components/tools/labels/filter.spec.tsx create mode 100644 web/app/components/tools/labels/selector.spec.tsx create mode 100644 web/app/components/tools/mcp/index.spec.tsx create mode 100644 web/app/components/tools/provider/custom-create-card.spec.tsx create mode 100644 web/app/components/tools/provider/empty.spec.tsx create mode 100644 web/app/components/tools/provider/tool-item.spec.tsx create mode 100644 web/app/components/tools/workflow-tool/method-selector.spec.tsx diff --git a/web/app/components/base/input-with-copy/index.spec.tsx b/web/app/components/base/input-with-copy/index.spec.tsx index 1a4319603e..a5628c473f 100644 --- a/web/app/components/base/input-with-copy/index.spec.tsx +++ b/web/app/components/base/input-with-copy/index.spec.tsx @@ -1,10 +1,20 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { createReactI18nextMock } from '@/test/i18n-mock' import InputWithCopy from './index' -// Mock navigator.clipboard for foxact/use-clipboard -const mockWriteText = vi.fn(() => Promise.resolve()) +// Create a controllable mock for useClipboard +const mockCopy = vi.fn() +let mockCopied = false +const mockReset = vi.fn() + +vi.mock('foxact/use-clipboard', () => ({ + useClipboard: () => ({ + copy: mockCopy, + copied: mockCopied, + reset: mockReset, + }), +})) // Mock the i18n hook with custom translations for test assertions vi.mock('react-i18next', () => createReactI18nextMock({ @@ -17,13 +27,9 @@ vi.mock('react-i18next', () => createReactI18nextMock({ describe('InputWithCopy component', () => { beforeEach(() => { vi.clearAllMocks() - mockWriteText.mockClear() - // Setup navigator.clipboard mock - Object.assign(navigator, { - clipboard: { - writeText: mockWriteText, - }, - }) + mockCopy.mockClear() + mockReset.mockClear() + mockCopied = false }) it('renders correctly with default props', () => { @@ -44,31 +50,27 @@ describe('InputWithCopy component', () => { expect(copyButton).not.toBeInTheDocument() }) - it('copies input value when copy button is clicked', async () => { + it('calls copy function with input value when copy button is clicked', () => { const mockOnChange = vi.fn() render() const copyButton = screen.getByRole('button') fireEvent.click(copyButton) - await waitFor(() => { - expect(mockWriteText).toHaveBeenCalledWith('test value') - }) + expect(mockCopy).toHaveBeenCalledWith('test value') }) - it('copies custom value when copyValue prop is provided', async () => { + it('calls copy function with custom value when copyValue prop is provided', () => { const mockOnChange = vi.fn() render() const copyButton = screen.getByRole('button') fireEvent.click(copyButton) - await waitFor(() => { - expect(mockWriteText).toHaveBeenCalledWith('custom copy value') - }) + expect(mockCopy).toHaveBeenCalledWith('custom copy value') }) - it('calls onCopy callback when copy button is clicked', async () => { + it('calls onCopy callback when copy button is clicked', () => { const onCopyMock = vi.fn() const mockOnChange = vi.fn() render() @@ -76,25 +78,21 @@ describe('InputWithCopy component', () => { const copyButton = screen.getByRole('button') fireEvent.click(copyButton) - await waitFor(() => { - expect(onCopyMock).toHaveBeenCalledWith('test value') - }) + expect(onCopyMock).toHaveBeenCalledWith('test value') }) - it('shows copied state after successful copy', async () => { + it('shows copied state when copied is true', () => { + mockCopied = true const mockOnChange = vi.fn() render() const copyButton = screen.getByRole('button') - fireEvent.click(copyButton) - // Hover over the button to trigger tooltip fireEvent.mouseEnter(copyButton) - // Check if the tooltip shows "Copied" state - await waitFor(() => { - expect(screen.getByText('Copied')).toBeInTheDocument() - }, { timeout: 2000 }) + // The icon should change to filled version when copied + // We verify the component renders without error in copied state + expect(copyButton).toBeInTheDocument() }) it('passes through all input props correctly', () => { @@ -117,22 +115,22 @@ describe('InputWithCopy component', () => { expect(input).toHaveClass('custom-class') }) - it('handles empty value correctly', async () => { + it('handles empty value correctly', () => { const mockOnChange = vi.fn() render() - const input = screen.getByDisplayValue('') + const input = screen.getByRole('textbox') const copyButton = screen.getByRole('button') expect(input).toBeInTheDocument() + expect(input).toHaveValue('') expect(copyButton).toBeInTheDocument() + // Clicking copy button with empty value should call copy with empty string fireEvent.click(copyButton) - await waitFor(() => { - expect(mockWriteText).toHaveBeenCalledWith('') - }) + expect(mockCopy).toHaveBeenCalledWith('') }) - it('maintains focus on input after copy', async () => { + it('maintains focus on input after copy', () => { const mockOnChange = vi.fn() render() diff --git a/web/app/components/datasets/common/check-rerank-model.spec.ts b/web/app/components/datasets/common/check-rerank-model.spec.ts new file mode 100644 index 0000000000..cba9b27200 --- /dev/null +++ b/web/app/components/datasets/common/check-rerank-model.spec.ts @@ -0,0 +1,426 @@ +import type { DefaultModelResponse, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { RetrievalConfig } from '@/types/app' +import { describe, expect, it } from 'vitest' +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { RerankingModeEnum } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { ensureRerankModelSelected, isReRankModelSelected } from './check-rerank-model' + +// Test data factory +const createRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +}) + +const createModelItem = (model: string): ModelItem => ({ + model, + label: { en_US: model, zh_Hans: model }, + model_type: ModelTypeEnum.rerank, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, +}) + +const createRerankModelList = (): Model[] => [ + { + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [ + createModelItem('gpt-4-turbo'), + createModelItem('gpt-3.5-turbo'), + ], + status: ModelStatusEnum.active, + }, + { + provider: 'cohere', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [ + createModelItem('rerank-english-v2.0'), + createModelItem('rerank-multilingual-v2.0'), + ], + status: ModelStatusEnum.active, + }, +] + +const createDefaultRerankModel = (): DefaultModelResponse => ({ + model: 'rerank-english-v2.0', + model_type: ModelTypeEnum.rerank, + provider: { + provider: 'cohere', + icon_small: { en_US: '', zh_Hans: '' }, + }, +}) + +describe('check-rerank-model', () => { + describe('isReRankModelSelected', () => { + describe('Core Functionality', () => { + it('should return true when reranking is disabled', () => { + const config = createRetrievalConfig({ + reranking_enable: false, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(true) + }) + + it('should return true for economy indexMethod', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'economy', + }) + + expect(result).toBe(true) + }) + + it('should return true when model is selected and valid', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v2.0', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(true) + }) + }) + + describe('Edge Cases', () => { + it('should return false when reranking enabled but no model selected for semantic search', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(false) + }) + + it('should return false when reranking enabled but no model selected for fullText search', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(false) + }) + + it('should return false for hybrid search without WeightedScore mode and no model selected', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(false) + }) + + it('should return true for hybrid search with WeightedScore mode even without model', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_mode: RerankingModeEnum.WeightedScore, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(true) + }) + + it('should return false when provider exists but model not found', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'non-existent-model', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(false) + }) + + it('should return false when provider not found in list', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'non-existent-provider', + reranking_model_name: 'some-model', + }, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: 'high_quality', + }) + + expect(result).toBe(false) + }) + + it('should return true with empty rerankModelList when reranking disabled', () => { + const config = createRetrievalConfig({ + reranking_enable: false, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: [], + indexMethod: 'high_quality', + }) + + expect(result).toBe(true) + }) + + it('should return true when indexMethod is undefined', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + }) + + const result = isReRankModelSelected({ + retrievalConfig: config, + rerankModelList: createRerankModelList(), + indexMethod: undefined, + }) + + expect(result).toBe(true) + }) + }) + }) + + describe('ensureRerankModelSelected', () => { + describe('Core Functionality', () => { + it('should return original config when reranking model already selected', () => { + const config = createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v2.0', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: 'high_quality', + }) + + expect(result).toEqual(config) + }) + + it('should apply default model when reranking enabled but no model selected', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: 'high_quality', + }) + + expect(result.reranking_model).toEqual({ + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v2.0', + }) + }) + + it('should apply default model for hybrid search method', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: 'high_quality', + }) + + expect(result.reranking_model).toEqual({ + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v2.0', + }) + }) + }) + + describe('Edge Cases', () => { + it('should return original config when indexMethod is not high_quality', () => { + const config = createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: 'economy', + }) + + expect(result).toEqual(config) + }) + + it('should return original config when rerankDefaultModel is null', () => { + const config = createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: null as unknown as DefaultModelResponse, + indexMethod: 'high_quality', + }) + + expect(result).toEqual(config) + }) + + it('should return original config when reranking disabled and not hybrid search', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: 'high_quality', + }) + + expect(result).toEqual(config) + }) + + it('should return original config when indexMethod is undefined', () => { + const config = createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: undefined, + }) + + expect(result).toEqual(config) + }) + + it('should preserve other config properties when applying default model', () => { + const config = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + top_k: 10, + score_threshold_enabled: true, + score_threshold: 0.8, + }) + + const result = ensureRerankModelSelected({ + retrievalConfig: config, + rerankDefaultModel: createDefaultRerankModel(), + indexMethod: 'high_quality', + }) + + expect(result.top_k).toBe(10) + expect(result.score_threshold_enabled).toBe(true) + expect(result.score_threshold).toBe(0.8) + expect(result.search_method).toBe(RETRIEVE_METHOD.semantic) + }) + }) + }) +}) diff --git a/web/app/components/datasets/common/chunking-mode-label.spec.tsx b/web/app/components/datasets/common/chunking-mode-label.spec.tsx new file mode 100644 index 0000000000..d01068c22f --- /dev/null +++ b/web/app/components/datasets/common/chunking-mode-label.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ChunkingModeLabel from './chunking-mode-label' + +describe('ChunkingModeLabel', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/general/i)).toBeInTheDocument() + }) + + it('should render with Badge wrapper', () => { + const { container } = render() + // Badge component renders with specific styles + expect(container.querySelector('.flex')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display general mode text when isGeneralMode is true', () => { + render() + expect(screen.getByText(/general/i)).toBeInTheDocument() + }) + + it('should display parent-child mode text when isGeneralMode is false', () => { + render() + expect(screen.getByText(/parentChild/i)).toBeInTheDocument() + }) + + it('should append QA suffix when isGeneralMode and isQAMode are both true', () => { + render() + expect(screen.getByText(/general.*QA/i)).toBeInTheDocument() + }) + + it('should not append QA suffix when isGeneralMode is true but isQAMode is false', () => { + render() + const text = screen.getByText(/general/i) + expect(text.textContent).not.toContain('QA') + }) + + it('should not display QA suffix for parent-child mode even when isQAMode is true', () => { + render() + expect(screen.getByText(/parentChild/i)).toBeInTheDocument() + expect(screen.queryByText(/QA/i)).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render icon element', () => { + const { container } = render() + const iconElement = container.querySelector('svg') + expect(iconElement).toBeInTheDocument() + }) + + it('should apply correct icon size classes', () => { + const { container } = render() + const iconElement = container.querySelector('svg') + expect(iconElement).toHaveClass('h-3', 'w-3') + }) + }) +}) diff --git a/web/app/components/datasets/common/credential-icon.spec.tsx b/web/app/components/datasets/common/credential-icon.spec.tsx new file mode 100644 index 0000000000..b1c3131dfe --- /dev/null +++ b/web/app/components/datasets/common/credential-icon.spec.tsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { CredentialIcon } from './credential-icon' + +describe('CredentialIcon', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('T')).toBeInTheDocument() + }) + + it('should render first letter when no avatar provided', () => { + render() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should render image when avatarUrl is provided', () => { + render() + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/avatar.png') + }) + }) + + describe('Props', () => { + it('should apply default size of 20px', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveStyle({ width: '20px', height: '20px' }) + }) + + it('should apply custom size', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveStyle({ width: '40px', height: '40px' }) + }) + + it('should apply custom className', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + + it('should uppercase the first letter', () => { + render() + expect(screen.getByText('B')).toBeInTheDocument() + }) + + it('should render fallback when avatarUrl is "default"', () => { + render() + expect(screen.getByText('T')).toBeInTheDocument() + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should fallback to letter when image fails to load', () => { + render() + + // Initially shows image + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + + // Trigger error event + fireEvent.error(img) + + // Should now show letter fallback + expect(screen.getByText('T')).toBeInTheDocument() + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle single character name', () => { + render() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should handle name starting with number', () => { + render() + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should handle name starting with special character', () => { + render() + expect(screen.getByText('@')).toBeInTheDocument() + }) + + it('should assign consistent background colors based on first letter', () => { + // Same first letter should get same color + const { container: container1 } = render() + const { container: container2 } = render() + + const wrapper1 = container1.firstChild as HTMLElement + const wrapper2 = container2.firstChild as HTMLElement + + // Both should have the same bg class since they start with 'A' + const classes1 = wrapper1.className + const classes2 = wrapper2.className + + const bgClass1 = classes1.match(/bg-components-icon-bg-\S+/)?.[0] + const bgClass2 = classes2.match(/bg-components-icon-bg-\S+/)?.[0] + + expect(bgClass1).toBe(bgClass2) + }) + + it('should apply different background colors for different letters', () => { + // 'A' (65) % 4 = 1 → pink, 'B' (66) % 4 = 2 → indigo + const { container: container1 } = render() + const { container: container2 } = render() + + const wrapper1 = container1.firstChild as HTMLElement + const wrapper2 = container2.firstChild as HTMLElement + + const bgClass1 = wrapper1.className.match(/bg-components-icon-bg-\S+/)?.[0] + const bgClass2 = wrapper2.className.match(/bg-components-icon-bg-\S+/)?.[0] + + expect(bgClass1).toBeDefined() + expect(bgClass2).toBeDefined() + expect(bgClass1).not.toBe(bgClass2) + }) + + it('should handle empty avatarUrl string', () => { + render() + expect(screen.getByText('T')).toBeInTheDocument() + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it('should render image with correct dimensions', () => { + render() + const img = screen.getByRole('img') + expect(img).toHaveAttribute('width', '32') + expect(img).toHaveAttribute('height', '32') + }) + }) +}) diff --git a/web/app/components/datasets/common/document-file-icon.spec.tsx b/web/app/components/datasets/common/document-file-icon.spec.tsx new file mode 100644 index 0000000000..25de278970 --- /dev/null +++ b/web/app/components/datasets/common/document-file-icon.spec.tsx @@ -0,0 +1,115 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import DocumentFileIcon from './document-file-icon' + +describe('DocumentFileIcon', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render FileTypeIcon component', () => { + const { container } = render() + // FileTypeIcon renders an svg or img element + expect(container.querySelector('svg, img')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should determine type from extension prop', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should determine type from name when extension not provided', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle uppercase extension', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle uppercase name extension', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render() + expect(container.querySelector('.custom-icon')).toBeInTheDocument() + }) + + it('should pass size prop to FileTypeIcon', () => { + // Testing different size values + const { container: smContainer } = render() + const { container: lgContainer } = render() + + expect(smContainer.firstChild).toBeInTheDocument() + expect(lgContainer.firstChild).toBeInTheDocument() + }) + }) + + describe('File Type Mapping', () => { + const testCases = [ + { extension: 'pdf', description: 'PDF files' }, + { extension: 'json', description: 'JSON files' }, + { extension: 'html', description: 'HTML files' }, + { extension: 'txt', description: 'TXT files' }, + { extension: 'markdown', description: 'Markdown files' }, + { extension: 'md', description: 'MD files' }, + { extension: 'xlsx', description: 'XLSX files' }, + { extension: 'xls', description: 'XLS files' }, + { extension: 'csv', description: 'CSV files' }, + { extension: 'doc', description: 'DOC files' }, + { extension: 'docx', description: 'DOCX files' }, + ] + + testCases.forEach(({ extension, description }) => { + it(`should handle ${description}`, () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle unknown extension with default document type', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle empty extension string', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle name without extension', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle name with multiple dots', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should prioritize extension over name', () => { + // If both are provided, extension should take precedence + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle undefined extension and name', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should apply default size of md', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.spec.tsx b/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.spec.tsx new file mode 100644 index 0000000000..0d9a87064b --- /dev/null +++ b/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.spec.tsx @@ -0,0 +1,166 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' + +import { useAutoDisabledDocuments } from '@/service/knowledge/use-document' +import AutoDisabledDocument from './auto-disabled-document' + +type AutoDisabledDocumentsResponse = { document_ids: string[] } + +const createMockQueryResult = ( + data: AutoDisabledDocumentsResponse | undefined, + isLoading: boolean, +) => ({ + data, + isLoading, +}) as ReturnType + +// Mock service hooks +const mockMutateAsync = vi.fn() +const mockInvalidDisabledDocument = vi.fn() + +vi.mock('@/service/knowledge/use-document', () => ({ + useAutoDisabledDocuments: vi.fn(), + useDocumentEnable: vi.fn(() => ({ + mutateAsync: mockMutateAsync, + })), + useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument), +})) + +// Mock Toast +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +const mockUseAutoDisabledDocuments = vi.mocked(useAutoDisabledDocuments) + +describe('AutoDisabledDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMutateAsync.mockResolvedValue({}) + }) + + describe('Rendering', () => { + it('should render nothing when loading', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult(undefined, true), + ) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when no disabled documents', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: [] }, false), + ) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when document_ids is undefined', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult(undefined, false), + ) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render StatusWithAction when disabled documents exist', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false), + ) + + render() + expect(screen.getByText(/enable/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should pass datasetId to useAutoDisabledDocuments', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: [] }, false), + ) + + render() + expect(mockUseAutoDisabledDocuments).toHaveBeenCalledWith('my-dataset-id') + }) + }) + + describe('User Interactions', () => { + it('should call enableDocument when action button is clicked', async () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false), + ) + + render() + + const actionButton = screen.getByText(/enable/i) + fireEvent.click(actionButton) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + datasetId: 'test-dataset', + documentIds: ['doc1', 'doc2'], + }) + }) + }) + + it('should invalidate cache after enabling documents', async () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: ['doc1'] }, false), + ) + + render() + + const actionButton = screen.getByText(/enable/i) + fireEvent.click(actionButton) + + await waitFor(() => { + expect(mockInvalidDisabledDocument).toHaveBeenCalled() + }) + }) + + it('should show success toast after enabling documents', async () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: ['doc1'] }, false), + ) + + render() + + const actionButton = screen.getByText(/enable/i) + fireEvent.click(actionButton) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'success', + message: expect.any(String), + }) + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle single disabled document', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: ['doc1'] }, false), + ) + + render() + expect(screen.getByText(/enable/i)).toBeInTheDocument() + }) + + it('should handle multiple disabled documents', () => { + mockUseAutoDisabledDocuments.mockReturnValue( + createMockQueryResult({ document_ids: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }, false), + ) + + render() + expect(screen.getByText(/enable/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx new file mode 100644 index 0000000000..ac24a2532f --- /dev/null +++ b/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx @@ -0,0 +1,280 @@ +import type { ErrorDocsResponse } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { retryErrorDocs } from '@/service/datasets' +import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset' +import RetryButton from './index-failed' + +// Mock service hooks +const mockRefetch = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetErrorDocs: vi.fn(), +})) + +vi.mock('@/service/datasets', () => ({ + retryErrorDocs: vi.fn(), +})) + +const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs) +const mockRetryErrorDocs = vi.mocked(retryErrorDocs) + +// Helper to create mock query result +const createMockQueryResult = ( + data: ErrorDocsResponse | undefined, + isLoading: boolean, +) => ({ + data, + isLoading, + refetch: mockRefetch, + // Required query result properties + error: null, + isError: false, + isFetched: true, + isFetching: false, + isSuccess: !isLoading && !!data, + status: isLoading ? 'pending' : 'success', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isLoadingError: false, + isPaused: false, + isPlaceholderData: false, + isPending: isLoading, + isRefetchError: false, + isRefetching: false, + isStale: false, + fetchStatus: 'idle', + promise: Promise.resolve(data as ErrorDocsResponse), + isFetchedAfterMount: true, + isInitialLoading: false, +}) as unknown as ReturnType + +describe('RetryButton (IndexFailed)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRefetch.mockResolvedValue({}) + }) + + describe('Rendering', () => { + it('should render nothing when loading', () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult(undefined, true), + ) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when no error documents', () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ total: 0, data: [] }, false), + ) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render StatusWithAction when error documents exist', () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 3, + data: [ + { id: 'doc1' }, + { id: 'doc2' }, + { id: 'doc3' }, + ] as ErrorDocsResponse['data'], + }, false), + ) + + render() + expect(screen.getByText(/retry/i)).toBeInTheDocument() + }) + + it('should display error count in description', () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 5, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, false), + ) + + render() + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should pass datasetId to useDatasetErrorDocs', () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ total: 0, data: [] }, false), + ) + + render() + expect(mockUseDatasetErrorDocs).toHaveBeenCalledWith('my-dataset-id') + }) + }) + + describe('User Interactions', () => { + it('should call retryErrorDocs when retry button is clicked', async () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 2, + data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'], + }, false), + ) + + mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) + + render() + + const retryButton = screen.getByText(/retry/i) + fireEvent.click(retryButton) + + await waitFor(() => { + expect(mockRetryErrorDocs).toHaveBeenCalledWith({ + datasetId: 'test-dataset', + document_ids: ['doc1', 'doc2'], + }) + }) + }) + + it('should refetch error docs after successful retry', async () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, false), + ) + + mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) + + render() + + const retryButton = screen.getByText(/retry/i) + fireEvent.click(retryButton) + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalled() + }) + }) + + it('should disable button while retrying', async () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, false), + ) + + // Delay the response to test loading state + mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100))) + + render() + + const retryButton = screen.getByText(/retry/i) + fireEvent.click(retryButton) + + // Button should show disabled styling during retry + await waitFor(() => { + const button = screen.getByText(/retry/i) + expect(button).toHaveClass('cursor-not-allowed') + expect(button).toHaveClass('text-text-disabled') + }) + }) + }) + + describe('State Management', () => { + it('should transition to error state when retry fails', async () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, false), + ) + + mockRetryErrorDocs.mockResolvedValue({ result: 'fail' }) + + render() + + const retryButton = screen.getByText(/retry/i) + fireEvent.click(retryButton) + + await waitFor(() => { + // Button should still be visible after failed retry + expect(screen.getByText(/retry/i)).toBeInTheDocument() + }) + }) + + it('should transition to success state when total becomes 0', async () => { + const { rerender } = render() + + // Initially has errors + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, false), + ) + + rerender() + expect(screen.getByText(/retry/i)).toBeInTheDocument() + + // Now no errors + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ total: 0, data: [] }, false), + ) + + rerender() + + await waitFor(() => { + expect(screen.queryByText(/retry/i)).not.toBeInTheDocument() + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty data array', () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ total: 0, data: [] }, false), + ) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should handle undefined data by showing error state', () => { + // When data is undefined but not loading, the component shows error state + // because errorDocs?.total is not strictly equal to 0 + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult(undefined, false), + ) + + render() + // Component renders with undefined count + expect(screen.getByText(/retry/i)).toBeInTheDocument() + }) + + it('should handle retry with empty document list', async () => { + mockUseDatasetErrorDocs.mockReturnValue( + createMockQueryResult({ total: 1, data: [] }, false), + ) + + mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) + + render() + + const retryButton = screen.getByText(/retry/i) + fireEvent.click(retryButton) + + await waitFor(() => { + expect(mockRetryErrorDocs).toHaveBeenCalledWith({ + datasetId: 'test-dataset', + document_ids: [], + }) + }) + }) + }) +}) diff --git a/web/app/components/datasets/common/document-status-with-action/status-with-action.spec.tsx b/web/app/components/datasets/common/document-status-with-action/status-with-action.spec.tsx new file mode 100644 index 0000000000..86db5d6e74 --- /dev/null +++ b/web/app/components/datasets/common/document-status-with-action/status-with-action.spec.tsx @@ -0,0 +1,175 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import StatusWithAction from './status-with-action' + +describe('StatusWithAction', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('Test description')).toBeInTheDocument() + }) + + it('should render description text', () => { + render() + expect(screen.getByText('This is a test message')).toBeInTheDocument() + }) + + it('should render icon based on type', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should default to info type when type is not provided', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-accent') + }) + + it('should render success type with correct color', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-success') + }) + + it('should render error type with correct color', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-destructive') + }) + + it('should render warning type with correct color', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-warning-secondary') + }) + + it('should render info type with correct color', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-accent') + }) + + it('should render action button when actionText and onAction are provided', () => { + const onAction = vi.fn() + render( + , + ) + expect(screen.getByText('Click me')).toBeInTheDocument() + }) + + it('should not render action button when onAction is not provided', () => { + render() + expect(screen.queryByText('Click me')).not.toBeInTheDocument() + }) + + it('should render divider when action is present', () => { + const { container } = render( + {}} + />, + ) + // Divider component renders a div with specific classes + expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onAction when action button is clicked', () => { + const onAction = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Click me')) + expect(onAction).toHaveBeenCalledTimes(1) + }) + + it('should call onAction even when disabled (style only)', () => { + // Note: disabled prop only affects styling, not actual click behavior + const onAction = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Click me')) + expect(onAction).toHaveBeenCalledTimes(1) + }) + + it('should apply disabled styles when disabled prop is true', () => { + render( + {}} + disabled + />, + ) + + const actionButton = screen.getByText('Click me') + expect(actionButton).toHaveClass('cursor-not-allowed') + expect(actionButton).toHaveClass('text-text-disabled') + }) + }) + + describe('Status Background Gradients', () => { + it('should apply success gradient background', () => { + const { container } = render() + const gradientDiv = container.querySelector('.opacity-40') + expect(gradientDiv?.className).toContain('rgba(23,178,106,0.25)') + }) + + it('should apply warning gradient background', () => { + const { container } = render() + const gradientDiv = container.querySelector('.opacity-40') + expect(gradientDiv?.className).toContain('rgba(247,144,9,0.25)') + }) + + it('should apply error gradient background', () => { + const { container } = render() + const gradientDiv = container.querySelector('.opacity-40') + expect(gradientDiv?.className).toContain('rgba(240,68,56,0.25)') + }) + + it('should apply info gradient background', () => { + const { container } = render() + const gradientDiv = container.querySelector('.opacity-40') + expect(gradientDiv?.className).toContain('rgba(11,165,236,0.25)') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty description', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle long description text', () => { + const longText = 'A'.repeat(500) + render() + expect(screen.getByText(longText)).toBeInTheDocument() + }) + + it('should handle undefined actionText when onAction is provided', () => { + render( {}} />) + // Should render without throwing + expect(screen.getByText('Test')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-list/index.spec.tsx b/web/app/components/datasets/common/image-list/index.spec.tsx new file mode 100644 index 0000000000..1951a21921 --- /dev/null +++ b/web/app/components/datasets/common/image-list/index.spec.tsx @@ -0,0 +1,252 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ImageList from './index' + +// Track handleImageClick calls for testing +type FileEntity = { + sourceUrl: string + name: string + mimeType?: string + size?: number + extension?: string +} + +let capturedOnClick: ((file: FileEntity) => void) | null = null + +// Mock FileThumb to capture click handler +vi.mock('@/app/components/base/file-thumb', () => ({ + default: ({ file, onClick }: { file: FileEntity, onClick?: (file: FileEntity) => void }) => { + // Capture the onClick for testing + capturedOnClick = onClick ?? null + return ( +
onClick?.(file)} + > + {file.name} +
+ ) + }, +})) + +type ImagePreviewerProps = { + images: ImageInfo[] + initialIndex: number + onClose: () => void +} + +type ImageInfo = { + url: string + name: string + size: number +} + +// Mock ImagePreviewer since it uses createPortal +vi.mock('../image-previewer', () => ({ + default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => ( +
+ {images.length} + {initialIndex} + +
+ ), +})) + +const createMockImages = (count: number) => { + return Array.from({ length: count }, (_, i) => ({ + name: `image-${i + 1}.png`, + mimeType: 'image/png', + sourceUrl: `https://example.com/image-${i + 1}.png`, + size: 1024 * (i + 1), + extension: 'png', + })) +} + +describe('ImageList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const images = createMockImages(3) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render all images when count is below limit', () => { + const images = createMockImages(5) + render() + // Each image renders a FileThumb component + const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]') + expect(thumbnails.length).toBeGreaterThanOrEqual(5) + }) + + it('should render limited images when count exceeds limit', () => { + const images = createMockImages(15) + render() + // More button should be visible + expect(screen.getByText(/\+6/)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const images = createMockImages(3) + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should use default limit of 9', () => { + const images = createMockImages(12) + render() + // Should show "+3" for remaining images + expect(screen.getByText(/\+3/)).toBeInTheDocument() + }) + + it('should respect custom limit', () => { + const images = createMockImages(10) + render() + // Should show "+5" for remaining images + expect(screen.getByText(/\+5/)).toBeInTheDocument() + }) + + it('should handle size prop sm', () => { + const images = createMockImages(2) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle size prop md', () => { + const images = createMockImages(2) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should show all images when More button is clicked', () => { + const images = createMockImages(15) + render() + + // Click More button + const moreButton = screen.getByText(/\+6/) + fireEvent.click(moreButton) + + // More button should disappear + expect(screen.queryByText(/\+6/)).not.toBeInTheDocument() + }) + + it('should open preview when image is clicked', () => { + const images = createMockImages(3) + render() + + // Find and click an image thumbnail + const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]') + if (thumbnails.length > 0) { + fireEvent.click(thumbnails[0]) + // Preview should open + expect(screen.getByTestId('image-previewer')).toBeInTheDocument() + } + }) + + it('should close preview when close button is clicked', () => { + const images = createMockImages(3) + render() + + // Open preview + const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]') + if (thumbnails.length > 0) { + fireEvent.click(thumbnails[0]) + + // Close preview + const closeButton = screen.getByTestId('close-preview') + fireEvent.click(closeButton) + + // Preview should be closed + expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument() + } + }) + }) + + describe('Edge Cases', () => { + it('should handle empty images array', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should not open preview when clicked image not found in list (index === -1)', () => { + const images = createMockImages(3) + const { rerender } = render() + + // Click first image to open preview + const firstThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png') + fireEvent.click(firstThumb) + + // Preview should open for valid image + expect(screen.getByTestId('image-previewer')).toBeInTheDocument() + + // Close preview + fireEvent.click(screen.getByTestId('close-preview')) + expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument() + + // Now render with images that don't include the previously clicked one + const newImages = createMockImages(2) // Only 2 images + rerender() + + // Click on a thumbnail that exists + const validThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png') + fireEvent.click(validThumb) + expect(screen.getByTestId('image-previewer')).toBeInTheDocument() + }) + + it('should return early when file sourceUrl is not found in limitedImages (index === -1)', () => { + const images = createMockImages(3) + render() + + // Call the captured onClick with a file that has a non-matching sourceUrl + // This triggers the index === -1 branch (line 44-45) + if (capturedOnClick) { + capturedOnClick({ + name: 'nonexistent.png', + mimeType: 'image/png', + sourceUrl: 'https://example.com/nonexistent.png', // Not in the list + size: 1024, + extension: 'png', + }) + } + + // Preview should NOT open because the file was not found in limitedImages + expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument() + }) + + it('should handle single image', () => { + const images = createMockImages(1) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should not show More button when images count equals limit', () => { + const images = createMockImages(9) + render() + expect(screen.queryByText(/\+/)).not.toBeInTheDocument() + }) + + it('should handle limit of 0', () => { + const images = createMockImages(5) + render() + // Should show "+5" for all images + expect(screen.getByText(/\+5/)).toBeInTheDocument() + }) + + it('should handle limit larger than images count', () => { + const images = createMockImages(5) + render() + // Should not show More button + expect(screen.queryByText(/\+/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-list/more.spec.tsx b/web/app/components/datasets/common/image-list/more.spec.tsx new file mode 100644 index 0000000000..bae20b69c5 --- /dev/null +++ b/web/app/components/datasets/common/image-list/more.spec.tsx @@ -0,0 +1,144 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import More from './more' + +describe('More', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('+5')).toBeInTheDocument() + }) + + it('should display count with plus sign', () => { + render() + expect(screen.getByText('+10')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should format count as-is when less than 1000', () => { + render() + expect(screen.getByText('+999')).toBeInTheDocument() + }) + + it('should format count with k suffix when 1000 or more', () => { + render() + expect(screen.getByText('+1.5k')).toBeInTheDocument() + }) + + it('should format count with M suffix when 1000000 or more', () => { + render() + expect(screen.getByText('+2.5M')).toBeInTheDocument() + }) + + it('should format 1000 as 1.0k', () => { + render() + expect(screen.getByText('+1.0k')).toBeInTheDocument() + }) + + it('should format 1000000 as 1.0M', () => { + render() + expect(screen.getByText('+1.0M')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByText('+5')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not throw when clicked without onClick', () => { + render() + + // Should not throw + expect(() => { + fireEvent.click(screen.getByText('+5')) + }).not.toThrow() + }) + + it('should stop event propagation on click', () => { + const parentClick = vi.fn() + const childClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('+5')) + expect(childClick).toHaveBeenCalled() + expect(parentClick).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should display +0 when count is 0', () => { + render() + expect(screen.getByText('+0')).toBeInTheDocument() + }) + + it('should handle count of 1', () => { + render() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('should handle boundary value 999', () => { + render() + expect(screen.getByText('+999')).toBeInTheDocument() + }) + + it('should handle boundary value 999999', () => { + render() + // 999999 / 1000 = 999.999 -> 1000.0k + expect(screen.getByText('+1000.0k')).toBeInTheDocument() + }) + + it('should apply cursor-pointer class', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + }) + }) + + describe('formatNumber branches', () => { + it('should return "0" when num equals 0', () => { + // This covers line 11-12: if (num === 0) return '0' + render() + expect(screen.getByText('+0')).toBeInTheDocument() + }) + + it('should return num.toString() when num < 1000 and num > 0', () => { + // This covers line 13-14: if (num < 1000) return num.toString() + render() + expect(screen.getByText('+500')).toBeInTheDocument() + }) + + it('should return k format when 1000 <= num < 1000000', () => { + // This covers line 15-16 + const { rerender } = render() + expect(screen.getByText('+5.0k')).toBeInTheDocument() + + rerender() + expect(screen.getByText('+1000.0k')).toBeInTheDocument() + + rerender() + expect(screen.getByText('+50.0k')).toBeInTheDocument() + }) + + it('should return M format when num >= 1000000', () => { + // This covers line 17 + const { rerender } = render() + expect(screen.getByText('+1.0M')).toBeInTheDocument() + + rerender() + expect(screen.getByText('+5.0M')).toBeInTheDocument() + + rerender() + expect(screen.getByText('+1000.0M')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-previewer/index.spec.tsx b/web/app/components/datasets/common/image-previewer/index.spec.tsx new file mode 100644 index 0000000000..01bdb111fb --- /dev/null +++ b/web/app/components/datasets/common/image-previewer/index.spec.tsx @@ -0,0 +1,525 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ImagePreviewer from './index' + +// Mock fetch +const mockFetch = vi.fn() +globalThis.fetch = mockFetch + +// Mock URL methods +const mockRevokeObjectURL = vi.fn() +const mockCreateObjectURL = vi.fn(() => 'blob:mock-url') +globalThis.URL.revokeObjectURL = mockRevokeObjectURL +globalThis.URL.createObjectURL = mockCreateObjectURL + +// Mock Image +class MockImage { + onload: (() => void) | null = null + onerror: (() => void) | null = null + _src = '' + + get src() { + return this._src + } + + set src(value: string) { + this._src = value + // Trigger onload after a microtask + setTimeout(() => { + if (this.onload) + this.onload() + }, 0) + } + + naturalWidth = 800 + naturalHeight = 600 +} +;(globalThis as unknown as { Image: typeof MockImage }).Image = MockImage + +const createMockImages = () => [ + { url: 'https://example.com/image1.png', name: 'image1.png', size: 1024 }, + { url: 'https://example.com/image2.png', name: 'image2.png', size: 2048 }, + { url: 'https://example.com/image3.png', name: 'image3.png', size: 3072 }, +] + +describe('ImagePreviewer', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default successful fetch mock + mockFetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + // Should render in portal + expect(document.body.querySelector('.image-previewer')).toBeInTheDocument() + }) + + it('should render close button', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + // Esc text should be visible + expect(screen.getByText('Esc')).toBeInTheDocument() + }) + + it('should show loading state initially', async () => { + const onClose = vi.fn() + const images = createMockImages() + + // Delay fetch to see loading state + mockFetch.mockImplementation(() => new Promise(() => {})) + + await act(async () => { + render() + }) + + // Loading component should be visible + expect(document.body.querySelector('.image-previewer')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should start at initialIndex', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + // Should start at second image + expect(screen.getByText('image2.png')).toBeInTheDocument() + }) + }) + + it('should default initialIndex to 0', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText('image1.png')).toBeInTheDocument() + }) + }) + }) + + describe('User Interactions', () => { + it('should call onClose when close button is clicked', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + // Find and click close button (the one with RiCloseLine icon) + const closeButton = document.querySelector('.absolute.right-6 button') + if (closeButton) { + fireEvent.click(closeButton) + expect(onClose).toHaveBeenCalledTimes(1) + } + }) + + it('should navigate to next image when next button is clicked', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText('image1.png')).toBeInTheDocument() + }) + + // Find and click next button (right arrow) + const buttons = document.querySelectorAll('button') + const nextButton = Array.from(buttons).find(btn => + btn.className.includes('right-8'), + ) + + if (nextButton) { + await act(async () => { + fireEvent.click(nextButton) + }) + + await waitFor(() => { + expect(screen.getByText('image2.png')).toBeInTheDocument() + }) + } + }) + + it('should navigate to previous image when prev button is clicked', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText('image2.png')).toBeInTheDocument() + }) + + // Find and click prev button (left arrow) + const buttons = document.querySelectorAll('button') + const prevButton = Array.from(buttons).find(btn => + btn.className.includes('left-8'), + ) + + if (prevButton) { + await act(async () => { + fireEvent.click(prevButton) + }) + + await waitFor(() => { + expect(screen.getByText('image1.png')).toBeInTheDocument() + }) + } + }) + + it('should disable prev button at first image', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + const buttons = document.querySelectorAll('button') + const prevButton = Array.from(buttons).find(btn => + btn.className.includes('left-8'), + ) + + expect(prevButton).toBeDisabled() + }) + + it('should disable next button at last image', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + const buttons = document.querySelectorAll('button') + const nextButton = Array.from(buttons).find(btn => + btn.className.includes('right-8'), + ) + + expect(nextButton).toBeDisabled() + }) + }) + + describe('Image Loading', () => { + it('should fetch images on mount', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + }) + }) + + it('should show error state when fetch fails', async () => { + const onClose = vi.fn() + const images = createMockImages() + + mockFetch.mockRejectedValue(new Error('Network error')) + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText(/Failed to load image/)).toBeInTheDocument() + }) + }) + + it('should show retry button on error', async () => { + const onClose = vi.fn() + const images = createMockImages() + + mockFetch.mockRejectedValue(new Error('Network error')) + + await act(async () => { + render() + }) + + await waitFor(() => { + // Retry button should be visible + const retryButton = document.querySelector('button.rounded-full') + expect(retryButton).toBeInTheDocument() + }) + }) + }) + + describe('Navigation Boundary Cases', () => { + it('should not navigate past first image when prevImage is called at index 0', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText('image1.png')).toBeInTheDocument() + }) + + // Click prev button multiple times - should stay at first image + const buttons = document.querySelectorAll('button') + const prevButton = Array.from(buttons).find(btn => + btn.className.includes('left-8'), + ) + + if (prevButton) { + await act(async () => { + fireEvent.click(prevButton) + fireEvent.click(prevButton) + }) + + // Should still be at first image + await waitFor(() => { + expect(screen.getByText('image1.png')).toBeInTheDocument() + }) + } + }) + + it('should not navigate past last image when nextImage is called at last index', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText('image3.png')).toBeInTheDocument() + }) + + // Click next button multiple times - should stay at last image + const buttons = document.querySelectorAll('button') + const nextButton = Array.from(buttons).find(btn => + btn.className.includes('right-8'), + ) + + if (nextButton) { + await act(async () => { + fireEvent.click(nextButton) + fireEvent.click(nextButton) + }) + + // Should still be at last image + await waitFor(() => { + expect(screen.getByText('image3.png')).toBeInTheDocument() + }) + } + }) + }) + + describe('Retry Functionality', () => { + it('should retry image load when retry button is clicked', async () => { + const onClose = vi.fn() + const images = createMockImages() + + // First fail, then succeed + let callCount = 0 + mockFetch.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.reject(new Error('Network error')) + } + return Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })), + }) + }) + + await act(async () => { + render() + }) + + // Wait for error state + await waitFor(() => { + expect(screen.getByText(/Failed to load image/)).toBeInTheDocument() + }) + + // Click retry button + const retryButton = document.querySelector('button.rounded-full') + if (retryButton) { + await act(async () => { + fireEvent.click(retryButton) + }) + + // Should refetch the image + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(4) // 3 initial + 1 retry + }) + } + }) + + it('should show retry button and call retryImage when clicked', async () => { + const onClose = vi.fn() + const images = createMockImages() + + mockFetch.mockRejectedValue(new Error('Network error')) + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(screen.getByText(/Failed to load image/)).toBeInTheDocument() + }) + + // Find and click the retry button (not the nav buttons) + const allButtons = document.querySelectorAll('button') + const retryButton = Array.from(allButtons).find(btn => + btn.className.includes('rounded-full') && !btn.className.includes('left-8') && !btn.className.includes('right-8'), + ) + + expect(retryButton).toBeInTheDocument() + + if (retryButton) { + mockFetch.mockClear() + mockFetch.mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })), + }) + + await act(async () => { + fireEvent.click(retryButton) + }) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + }) + } + }) + }) + + describe('Image Cache', () => { + it('should clean up blob URLs on unmount', async () => { + const onClose = vi.fn() + const images = createMockImages() + + // First render to populate cache + const { unmount } = await act(async () => { + const result = render() + return result + }) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + }) + + // Store the call count for verification + const _firstCallCount = mockFetch.mock.calls.length + + unmount() + + // Note: The imageCache is cleared on unmount, so this test verifies + // the cleanup behavior rather than caching across mounts + expect(mockRevokeObjectURL).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle single image', async () => { + const onClose = vi.fn() + const images = [createMockImages()[0]] + + await act(async () => { + render() + }) + + // Both navigation buttons should be disabled + const buttons = document.querySelectorAll('button') + const prevButton = Array.from(buttons).find(btn => + btn.className.includes('left-8'), + ) + const nextButton = Array.from(buttons).find(btn => + btn.className.includes('right-8'), + ) + + expect(prevButton).toBeDisabled() + expect(nextButton).toBeDisabled() + }) + + it('should stop event propagation on container click', async () => { + const onClose = vi.fn() + const parentClick = vi.fn() + const images = createMockImages() + + await act(async () => { + render( +
+ +
, + ) + }) + + const container = document.querySelector('.image-previewer') + if (container) { + fireEvent.click(container) + expect(parentClick).not.toHaveBeenCalled() + } + }) + + it('should display image dimensions when loaded', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + // Should display dimensions (800 × 600 from MockImage) + expect(screen.getByText(/800.*600/)).toBeInTheDocument() + }) + }) + + it('should display file size', async () => { + const onClose = vi.fn() + const images = createMockImages() + + await act(async () => { + render() + }) + + await waitFor(() => { + // Should display formatted file size + expect(screen.getByText('image1.png')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/hooks/use-upload.spec.tsx b/web/app/components/datasets/common/image-uploader/hooks/use-upload.spec.tsx new file mode 100644 index 0000000000..e62deac165 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/hooks/use-upload.spec.tsx @@ -0,0 +1,922 @@ +import type { PropsWithChildren } from 'react' +import type { FileEntity } from '../types' +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' +import { FileContextProvider } from '../store' +import { useUpload } from './use-upload' + +// Mock dependencies +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +type FileUploadOptions = { + file: File + onProgressCallback?: (progress: number) => void + onSuccessCallback?: (res: { id: string, extension: string, mime_type: string, size: number }) => void + onErrorCallback?: (error?: Error) => void +} + +const mockFileUpload = vi.fn<(options: FileUploadOptions) => void>() +const mockGetFileUploadErrorMessage = vi.fn(() => 'Upload error') + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + fileUpload: (options: FileUploadOptions) => mockFileUpload(options), + getFileUploadErrorMessage: () => mockGetFileUploadErrorMessage(), +})) + +const createWrapper = () => { + return ({ children }: PropsWithChildren) => ( + + {children} + + ) +} + +const createMockFile = (name = 'test.png', _size = 1024, type = 'image/png') => { + return new File(['test content'], name, { type }) +} + +// Mock FileReader +type EventCallback = () => void + +class MockFileReader { + result: string | ArrayBuffer | null = null + onload: EventCallback | null = null + onerror: EventCallback | null = null + private listeners: Record = {} + + addEventListener(event: string, callback: EventCallback) { + if (!this.listeners[event]) + this.listeners[event] = [] + this.listeners[event].push(callback) + } + + removeEventListener(event: string, callback: EventCallback) { + if (this.listeners[event]) + this.listeners[event] = this.listeners[event].filter(cb => cb !== callback) + } + + readAsDataURL(_file: File) { + setTimeout(() => { + this.result = 'data:image/png;base64,mockBase64Data' + this.listeners.load?.forEach(cb => cb()) + }, 0) + } + + triggerError() { + this.listeners.error?.forEach(cb => cb()) + } +} + +describe('useUpload hook', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFileUpload.mockImplementation(({ onSuccessCallback }) => { + setTimeout(() => { + onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 }) + }, 0) + }) + // Mock FileReader globally + vi.stubGlobal('FileReader', MockFileReader) + }) + + describe('Initialization', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(result.current.dragging).toBe(false) + expect(result.current.uploaderRef).toBeDefined() + expect(result.current.dragRef).toBeDefined() + expect(result.current.dropRef).toBeDefined() + }) + + it('should return file upload config', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(result.current.fileUploadConfig).toBeDefined() + expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10) + expect(result.current.fileUploadConfig.singleChunkAttachmentLimit).toBe(20) + expect(result.current.fileUploadConfig.imageFileSizeLimit).toBe(15) + }) + }) + + describe('File Operations', () => { + it('should expose selectHandle function', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.selectHandle).toBe('function') + }) + + it('should expose fileChangeHandle function', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.fileChangeHandle).toBe('function') + }) + + it('should expose handleRemoveFile function', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.handleRemoveFile).toBe('function') + }) + + it('should expose handleReUploadFile function', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.handleReUploadFile).toBe('function') + }) + + it('should expose handleLocalFileUpload function', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.handleLocalFileUpload).toBe('function') + }) + }) + + describe('File Validation', () => { + it('should show error toast for invalid file type', async () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + const mockEvent = { + target: { + files: [createMockFile('test.exe', 1024, 'application/x-msdownload')], + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(mockEvent) + }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + }) + }) + + it('should not reject valid image file types', async () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + const mockFile = createMockFile('test.png', 1024, 'image/png') + + const mockEvent = { + target: { + files: [mockFile], + }, + } as unknown as React.ChangeEvent + + // File type validation should pass for png files + // The actual upload will fail without proper FileReader mock, + // but we're testing that type validation doesn't reject valid files + act(() => { + result.current.fileChangeHandle(mockEvent) + }) + + // Should not show type error for valid image type + type ToastCall = [{ type: string, message: string }] + const mockNotify = vi.mocked(Toast.notify) + const calls = mockNotify.mock.calls as ToastCall[] + const typeErrorCalls = calls.filter( + (call: ToastCall) => call[0].type === 'error' && call[0].message.includes('Extension'), + ) + expect(typeErrorCalls.length).toBe(0) + }) + }) + + describe('Drag and Drop Refs', () => { + it('should provide dragRef', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(result.current.dragRef).toBeDefined() + expect(result.current.dragRef.current).toBeNull() + }) + + it('should provide dropRef', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(result.current.dropRef).toBeDefined() + expect(result.current.dropRef.current).toBeNull() + }) + + it('should provide uploaderRef', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(result.current.uploaderRef).toBeDefined() + expect(result.current.uploaderRef.current).toBeNull() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty file list', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + const mockEvent = { + target: { + files: [], + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(mockEvent) + }) + + // Should not throw and not show error + expect(Toast.notify).not.toHaveBeenCalled() + }) + + it('should handle null files', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + const mockEvent = { + target: { + files: null, + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(mockEvent) + }) + + // Should not throw + expect(true).toBe(true) + }) + + it('should respect batch limit from config', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + // Config should have batch limit of 10 + expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10) + }) + }) + + describe('File Size Validation', () => { + it('should show error for files exceeding size limit', async () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + // Create a file larger than 15MB limit (15 * 1024 * 1024 bytes) + const largeFile = new File(['x'.repeat(16 * 1024 * 1024)], 'large.png', { type: 'image/png' }) + Object.defineProperty(largeFile, 'size', { value: 16 * 1024 * 1024 }) + + const mockEvent = { + target: { + files: [largeFile], + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(mockEvent) + }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + }) + }) + }) + + describe('handleRemoveFile', () => { + it('should remove file from store', async () => { + const onChange = vi.fn() + const initialFiles: Partial[] = [ + { id: 'file1', name: 'test1.png', progress: 100 }, + { id: 'file2', name: 'test2.png', progress: 100 }, + ] + + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + act(() => { + result.current.handleRemoveFile('file1') + }) + + expect(onChange).toHaveBeenCalledWith([ + { id: 'file2', name: 'test2.png', progress: 100 }, + ]) + }) + }) + + describe('handleReUploadFile', () => { + it('should re-upload file when called with valid fileId', async () => { + const onChange = vi.fn() + const initialFiles: Partial[] = [ + { id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') }, + ] + + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + act(() => { + result.current.handleReUploadFile('file1') + }) + + await waitFor(() => { + expect(mockFileUpload).toHaveBeenCalled() + }) + }) + + it('should not re-upload when fileId is not found', () => { + const onChange = vi.fn() + const initialFiles: Partial[] = [ + { id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') }, + ] + + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + act(() => { + result.current.handleReUploadFile('nonexistent') + }) + + // fileUpload should not be called for nonexistent file + expect(mockFileUpload).not.toHaveBeenCalled() + }) + + it('should handle upload error during re-upload', async () => { + mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => { + setTimeout(() => { + onErrorCallback?.(new Error('Upload failed')) + }, 0) + }) + + const onChange = vi.fn() + const initialFiles: Partial[] = [ + { id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') }, + ] + + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + act(() => { + result.current.handleReUploadFile('file1') + }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: 'Upload error', + }) + }) + }) + }) + + describe('handleLocalFileUpload', () => { + it('should upload file and update progress', async () => { + mockFileUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }: FileUploadOptions) => { + setTimeout(() => { + onProgressCallback?.(50) + setTimeout(() => { + onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 }) + }, 10) + }, 0) + }) + + const onChange = vi.fn() + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + const mockFile = createMockFile('test.png', 1024, 'image/png') + + await act(async () => { + result.current.handleLocalFileUpload(mockFile) + }) + + await waitFor(() => { + expect(mockFileUpload).toHaveBeenCalled() + }) + }) + + it('should handle upload error', async () => { + mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => { + setTimeout(() => { + onErrorCallback?.(new Error('Upload failed')) + }, 0) + }) + + const onChange = vi.fn() + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + const mockFile = createMockFile('test.png', 1024, 'image/png') + + await act(async () => { + result.current.handleLocalFileUpload(mockFile) + }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: 'Upload error', + }) + }) + }) + }) + + describe('Attachment Limit', () => { + it('should show error when exceeding single chunk attachment limit', async () => { + const onChange = vi.fn() + // Pre-populate with 19 files (limit is 20) + const initialFiles: Partial[] = Array.from({ length: 19 }, (_, i) => ({ + id: `file${i}`, + name: `test${i}.png`, + progress: 100, + })) + + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + // Try to add 2 more files (would exceed limit of 20) + const mockEvent = { + target: { + files: [ + createMockFile('new1.png'), + createMockFile('new2.png'), + ], + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(mockEvent) + }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + }) + }) + }) + + describe('selectHandle', () => { + it('should trigger click on uploader input when called', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + // Create a mock input element + const mockInput = document.createElement('input') + const clickSpy = vi.spyOn(mockInput, 'click') + + // Manually set the ref + Object.defineProperty(result.current.uploaderRef, 'current', { + value: mockInput, + writable: true, + }) + + act(() => { + result.current.selectHandle() + }) + + expect(clickSpy).toHaveBeenCalled() + }) + + it('should not throw when uploaderRef is null', () => { + const { result } = renderHook(() => useUpload(), { + wrapper: createWrapper(), + }) + + expect(() => { + act(() => { + result.current.selectHandle() + }) + }).not.toThrow() + }) + }) + + describe('FileReader Error Handling', () => { + it('should show error toast when FileReader encounters an error', async () => { + // Create a custom MockFileReader that triggers error + class ErrorFileReader { + result: string | ArrayBuffer | null = null + private listeners: Record = {} + + addEventListener(event: string, callback: EventCallback) { + if (!this.listeners[event]) + this.listeners[event] = [] + this.listeners[event].push(callback) + } + + removeEventListener(event: string, callback: EventCallback) { + if (this.listeners[event]) + this.listeners[event] = this.listeners[event].filter(cb => cb !== callback) + } + + readAsDataURL(_file: File) { + // Trigger error instead of load + setTimeout(() => { + this.listeners.error?.forEach(cb => cb()) + }, 0) + } + } + + vi.stubGlobal('FileReader', ErrorFileReader) + + const onChange = vi.fn() + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useUpload(), { wrapper }) + + const mockFile = createMockFile('test.png', 1024, 'image/png') + + await act(async () => { + result.current.handleLocalFileUpload(mockFile) + }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + }) + + // Restore original MockFileReader + vi.stubGlobal('FileReader', MockFileReader) + }) + }) + + describe('Drag and Drop Functionality', () => { + // Test component that renders the hook with actual DOM elements + const TestComponent = ({ onStateChange }: { onStateChange?: (dragging: boolean) => void }) => { + const { dragging, dragRef, dropRef } = useUpload() + + // Report dragging state changes to parent + React.useEffect(() => { + onStateChange?.(dragging) + }, [dragging, onStateChange]) + + return ( +
+
+ {dragging ? 'dragging' : 'not-dragging'} +
+
+ ) + } + + it('should set dragging to true on dragEnter when target is not dragRef', async () => { + const onStateChange = vi.fn() + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + + // Fire dragenter event on dropZone (not dragRef) + await act(async () => { + fireEvent.dragEnter(dropZone, { + dataTransfer: { items: [] }, + }) + }) + + // Verify dragging state changed to true + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + }) + + it('should set dragging to false on dragLeave when target matches dragRef', async () => { + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + const dragBoundary = screen.getByTestId('drag-boundary') + + // First trigger dragenter to set dragging to true + await act(async () => { + fireEvent.dragEnter(dropZone, { + dataTransfer: { items: [] }, + }) + }) + + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + + // Then trigger dragleave on dragBoundary to set dragging to false + await act(async () => { + fireEvent.dragLeave(dragBoundary, { + dataTransfer: { items: [] }, + }) + }) + + expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging') + }) + + it('should handle drop event with files and reset dragging state', async () => { + const onChange = vi.fn() + + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + const mockFile = new File(['test content'], 'test.png', { type: 'image/png' }) + + // First trigger dragenter + await act(async () => { + fireEvent.dragEnter(dropZone, { + dataTransfer: { items: [] }, + }) + }) + + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + + // Then trigger drop with files + await act(async () => { + fireEvent.drop(dropZone, { + dataTransfer: { + items: [{ + webkitGetAsEntry: () => null, + getAsFile: () => mockFile, + }], + }, + }) + }) + + // Dragging should be reset to false after drop + expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging') + }) + + it('should return early when dataTransfer is null on drop', async () => { + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + + // Fire dragenter first + await act(async () => { + fireEvent.dragEnter(dropZone) + }) + + // Fire drop without dataTransfer + await act(async () => { + fireEvent.drop(dropZone) + }) + + // Should still reset dragging state + expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging') + }) + + it('should not trigger file upload for invalid file types on drop', async () => { + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + const invalidFile = new File(['test'], 'test.exe', { type: 'application/x-msdownload' }) + + await act(async () => { + fireEvent.drop(dropZone, { + dataTransfer: { + items: [{ + webkitGetAsEntry: () => null, + getAsFile: () => invalidFile, + }], + }, + }) + }) + + // Should show error toast for invalid file type + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + }) + }) + + it('should handle drop with webkitGetAsEntry for file entries', async () => { + const onChange = vi.fn() + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }) + + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + + // Create a mock file entry that simulates webkitGetAsEntry behavior + const mockFileEntry = { + isFile: true, + isDirectory: false, + file: (callback: (file: File) => void) => callback(mockFile), + } + + await act(async () => { + fireEvent.drop(dropZone, { + dataTransfer: { + items: [{ + webkitGetAsEntry: () => mockFileEntry, + getAsFile: () => mockFile, + }], + }, + }) + }) + + // Dragging should be reset + expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging') + }) + }) + + describe('Drag Events', () => { + const TestComponent = () => { + const { dragging, dragRef, dropRef } = useUpload() + return ( +
+
+ {dragging ? 'dragging' : 'not-dragging'} +
+
+ ) + } + + it('should handle dragEnter event and update dragging state', async () => { + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + + // Initially not dragging + expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging') + + // Fire dragEnter + await act(async () => { + fireEvent.dragEnter(dropZone, { + dataTransfer: { items: [] }, + }) + }) + + // Should be dragging now + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + }) + + it('should handle dragOver event without changing state', async () => { + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + + // First trigger dragenter to set dragging + await act(async () => { + fireEvent.dragEnter(dropZone) + }) + + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + + // dragOver should not change the dragging state + await act(async () => { + fireEvent.dragOver(dropZone) + }) + + // Should still be dragging + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + }) + + it('should not set dragging to true when dragEnter target is dragRef', async () => { + render( + + + , + ) + + const dragBoundary = screen.getByTestId('drag-boundary') + + // Fire dragEnter directly on dragRef + await act(async () => { + fireEvent.dragEnter(dragBoundary) + }) + + // Should not be dragging when target is dragRef itself + expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging') + }) + + it('should not set dragging to false when dragLeave target is not dragRef', async () => { + render( + + + , + ) + + const dropZone = screen.getByTestId('drop-zone') + + // First trigger dragenter on dropZone to set dragging + await act(async () => { + fireEvent.dragEnter(dropZone) + }) + + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + + // dragLeave on dropZone (not dragRef) should not change dragging state + await act(async () => { + fireEvent.dragLeave(dropZone) + }) + + // Should still be dragging (only dragLeave on dragRef resets) + expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging') + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.spec.tsx new file mode 100644 index 0000000000..1359f58637 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.spec.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { FileContextProvider } from '../store' +import ImageInput from './image-input' + +// Mock dependencies +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), +})) + +const renderWithProvider = (ui: React.ReactElement) => { + return render( + + {ui} + , + ) +} + +describe('ImageInput (image-uploader-in-chunk)', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderWithProvider() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render file input element', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toBeInTheDocument() + }) + + it('should have hidden file input', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toHaveClass('hidden') + }) + + it('should render upload icon', () => { + const { container } = renderWithProvider() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + + it('should render browse text', () => { + renderWithProvider() + expect(screen.getByText(/browse/i)).toBeInTheDocument() + }) + }) + + describe('File Input Props', () => { + it('should accept multiple files', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toHaveAttribute('multiple') + }) + + it('should have accept attribute for images', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toHaveAttribute('accept') + }) + }) + + describe('User Interactions', () => { + it('should open file dialog when browse is clicked', () => { + renderWithProvider() + + const browseText = screen.getByText(/browse/i) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(input, 'click') + + fireEvent.click(browseText) + + expect(clickSpy).toHaveBeenCalled() + }) + }) + + describe('Drag and Drop', () => { + it('should have drop zone area', () => { + const { container } = renderWithProvider() + // The drop zone has dashed border styling + expect(container.querySelector('.border-dashed')).toBeInTheDocument() + }) + + it('should apply accent styles when dragging', () => { + // This would require simulating drag events + // Just verify the base structure exists + const { container } = renderWithProvider() + expect(container.querySelector('.border-components-dropzone-border')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should display file size limit from config', () => { + renderWithProvider() + // The tip text should contain the size limit (15 from mock) + const tipText = document.querySelector('.system-xs-regular') + expect(tipText).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.spec.tsx new file mode 100644 index 0000000000..55c9d4d267 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.spec.tsx @@ -0,0 +1,198 @@ +import type { FileEntity } from '../types' +import { fireEvent, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ImageItem from './image-item' + +const createMockFile = (overrides: Partial = {}): FileEntity => ({ + id: 'test-id', + name: 'test.png', + progress: 100, + base64Url: 'data:image/png;base64,test', + sourceUrl: 'https://example.com/test.png', + size: 1024, + ...overrides, +} as FileEntity) + +describe('ImageItem (image-uploader-in-chunk)', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const file = createMockFile() + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render image preview', () => { + const file = createMockFile() + const { container } = render() + // FileImageRender component should be present + expect(container.querySelector('.group\\/file-image')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should show delete button when showDeleteAction is true', () => { + const file = createMockFile() + const { container } = render( + {}} />, + ) + // Delete button has RiCloseLine icon + const deleteButton = container.querySelector('button') + expect(deleteButton).toBeInTheDocument() + }) + + it('should not show delete button when showDeleteAction is false', () => { + const file = createMockFile() + const { container } = render() + const deleteButton = container.querySelector('button') + expect(deleteButton).not.toBeInTheDocument() + }) + + it('should use base64Url for image when available', () => { + const file = createMockFile({ base64Url: 'data:image/png;base64,custom' }) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should fallback to sourceUrl when base64Url is not available', () => { + const file = createMockFile({ base64Url: undefined }) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Progress States', () => { + it('should show progress indicator when progress is between 0 and 99', () => { + const file = createMockFile({ progress: 50, uploadedId: undefined }) + const { container } = render() + // Progress circle should be visible + expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument() + }) + + it('should not show progress indicator when upload is complete', () => { + const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' }) + const { container } = render() + expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument() + }) + + it('should show retry button when progress is -1 (error)', () => { + const file = createMockFile({ progress: -1 }) + const { container } = render() + // Error state shows destructive overlay + expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onPreview when image is clicked', () => { + const onPreview = vi.fn() + const file = createMockFile() + const { container } = render() + + const imageContainer = container.querySelector('.group\\/file-image') + if (imageContainer) { + fireEvent.click(imageContainer) + expect(onPreview).toHaveBeenCalledWith('test-id') + } + }) + + it('should call onRemove when delete button is clicked', () => { + const onRemove = vi.fn() + const file = createMockFile() + const { container } = render( + , + ) + + const deleteButton = container.querySelector('button') + if (deleteButton) { + fireEvent.click(deleteButton) + expect(onRemove).toHaveBeenCalledWith('test-id') + } + }) + + it('should call onReUpload when error overlay is clicked', () => { + const onReUpload = vi.fn() + const file = createMockFile({ progress: -1 }) + const { container } = render() + + const errorOverlay = container.querySelector('.bg-background-overlay-destructive') + if (errorOverlay) { + fireEvent.click(errorOverlay) + expect(onReUpload).toHaveBeenCalledWith('test-id') + } + }) + + it('should stop event propagation on delete button click', () => { + const onRemove = vi.fn() + const onPreview = vi.fn() + const file = createMockFile() + const { container } = render( + , + ) + + const deleteButton = container.querySelector('button') + if (deleteButton) { + fireEvent.click(deleteButton) + expect(onRemove).toHaveBeenCalled() + expect(onPreview).not.toHaveBeenCalled() + } + }) + + it('should stop event propagation on retry click', () => { + const onReUpload = vi.fn() + const onPreview = vi.fn() + const file = createMockFile({ progress: -1 }) + const { container } = render( + , + ) + + const errorOverlay = container.querySelector('.bg-background-overlay-destructive') + if (errorOverlay) { + fireEvent.click(errorOverlay) + expect(onReUpload).toHaveBeenCalled() + // onPreview should not be called due to stopPropagation + } + }) + }) + + describe('Edge Cases', () => { + it('should handle missing onPreview callback', () => { + const file = createMockFile() + const { container } = render() + + const imageContainer = container.querySelector('.group\\/file-image') + expect(() => { + if (imageContainer) + fireEvent.click(imageContainer) + }).not.toThrow() + }) + + it('should handle missing onRemove callback', () => { + const file = createMockFile() + const { container } = render() + + const deleteButton = container.querySelector('button') + expect(() => { + if (deleteButton) + fireEvent.click(deleteButton) + }).not.toThrow() + }) + + it('should handle missing onReUpload callback', () => { + const file = createMockFile({ progress: -1 }) + const { container } = render() + + const errorOverlay = container.querySelector('.bg-background-overlay-destructive') + expect(() => { + if (errorOverlay) + fireEvent.click(errorOverlay) + }).not.toThrow() + }) + + it('should handle progress of 0', () => { + const file = createMockFile({ progress: 0 }) + const { container } = render() + // Progress overlay should be visible at 0% + expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.spec.tsx new file mode 100644 index 0000000000..aaa56fd7e0 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.spec.tsx @@ -0,0 +1,167 @@ +import type { FileEntity } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ImageUploaderInChunkWrapper from './index' + +// Mock dependencies +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), +})) + +vi.mock('@/app/components/datasets/common/image-previewer', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +describe('ImageUploaderInChunk', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render ImageInput when not disabled', () => { + const onChange = vi.fn() + render() + // ImageInput renders an input element + expect(document.querySelector('input[type="file"]')).toBeInTheDocument() + }) + + it('should not render ImageInput when disabled', () => { + const onChange = vi.fn() + render() + // ImageInput should not be present + expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should render files when value is provided', () => { + const onChange = vi.fn() + const files: FileEntity[] = [ + { + id: 'file1', + name: 'test1.png', + extension: 'png', + mimeType: 'image/png', + progress: 100, + base64Url: 'data:image/png;base64,test1', + size: 1024, + }, + { + id: 'file2', + name: 'test2.png', + extension: 'png', + mimeType: 'image/png', + progress: 100, + base64Url: 'data:image/png;base64,test2', + size: 2048, + }, + ] + + render() + // Each file renders an ImageItem + const fileItems = document.querySelectorAll('.group\\/file-image') + expect(fileItems.length).toBeGreaterThanOrEqual(2) + }) + }) + + describe('User Interactions', () => { + it('should show preview when image is clicked', () => { + const onChange = vi.fn() + const files: FileEntity[] = [ + { + id: 'file1', + name: 'test.png', + extension: 'png', + mimeType: 'image/png', + progress: 100, + uploadedId: 'uploaded-1', + base64Url: 'data:image/png;base64,test', + size: 1024, + }, + ] + + render() + + // Find and click the file item + const fileItem = document.querySelector('.group\\/file-image') + if (fileItem) { + fireEvent.click(fileItem) + expect(screen.getByTestId('image-previewer')).toBeInTheDocument() + } + }) + + it('should close preview when close button is clicked', () => { + const onChange = vi.fn() + const files: FileEntity[] = [ + { + id: 'file1', + name: 'test.png', + extension: 'png', + mimeType: 'image/png', + progress: 100, + uploadedId: 'uploaded-1', + base64Url: 'data:image/png;base64,test', + size: 1024, + }, + ] + + render() + + // Open preview + const fileItem = document.querySelector('.group\\/file-image') + if (fileItem) { + fireEvent.click(fileItem) + + // Close preview + const closeButton = screen.getByTestId('close-preview') + fireEvent.click(closeButton) + + expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument() + } + }) + }) + + describe('Edge Cases', () => { + it('should handle empty files array', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle undefined value', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.spec.tsx new file mode 100644 index 0000000000..705cf7b949 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.spec.tsx @@ -0,0 +1,125 @@ +import type { FileEntity } from '../types' +import { fireEvent, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { FileContextProvider } from '../store' +import ImageInput from './image-input' + +// Mock dependencies +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), +})) + +const renderWithProvider = (ui: React.ReactElement, initialFiles: FileEntity[] = []) => { + return render( + + {ui} + , + ) +} + +describe('ImageInput (image-uploader-in-retrieval-testing)', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderWithProvider() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render file input element', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toBeInTheDocument() + }) + + it('should have hidden file input', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toHaveClass('hidden') + }) + + it('should render add image icon', () => { + const { container } = renderWithProvider() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + + it('should show tip text when no files are uploaded', () => { + renderWithProvider() + // Tip text should be visible + expect(document.querySelector('.system-sm-regular')).toBeInTheDocument() + }) + + it('should hide tip text when files exist', () => { + const files: FileEntity[] = [ + { + id: 'file1', + name: 'test.png', + extension: 'png', + mimeType: 'image/png', + size: 1024, + progress: 100, + uploadedId: 'uploaded-1', + }, + ] + renderWithProvider(, files) + // Tip text should not be visible + expect(document.querySelector('.text-text-quaternary')).not.toBeInTheDocument() + }) + }) + + describe('File Input Props', () => { + it('should accept multiple files', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toHaveAttribute('multiple') + }) + + it('should have accept attribute', () => { + renderWithProvider() + const input = document.querySelector('input[type="file"]') + expect(input).toHaveAttribute('accept') + }) + }) + + describe('User Interactions', () => { + it('should open file dialog when icon is clicked', () => { + renderWithProvider() + + const clickableArea = document.querySelector('.cursor-pointer') + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(input, 'click') + + if (clickableArea) + fireEvent.click(clickableArea) + + expect(clickSpy).toHaveBeenCalled() + }) + }) + + describe('Tooltip', () => { + it('should have tooltip component', () => { + const { container } = renderWithProvider() + // Tooltip wrapper should exist + expect(container.firstChild).toBeInTheDocument() + }) + + it('should disable tooltip when no files exist', () => { + // When files.length === 0, tooltip should be disabled + renderWithProvider() + // Component renders with tip text visible instead of tooltip + expect(document.querySelector('.system-sm-regular')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render icon container with correct styling', () => { + const { container } = renderWithProvider() + expect(container.querySelector('.border-dashed')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.spec.tsx new file mode 100644 index 0000000000..5725d3ed7f --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.spec.tsx @@ -0,0 +1,149 @@ +import type { FileEntity } from '../types' +import { fireEvent, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ImageItem from './image-item' + +const createMockFile = (overrides: Partial = {}): FileEntity => ({ + id: 'test-id', + name: 'test.png', + progress: 100, + base64Url: 'data:image/png;base64,test', + sourceUrl: 'https://example.com/test.png', + size: 1024, + ...overrides, +} as FileEntity) + +describe('ImageItem (image-uploader-in-retrieval-testing)', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const file = createMockFile() + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render with size-20 class', () => { + const file = createMockFile() + const { container } = render() + expect(container.querySelector('.size-20')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should show delete button when showDeleteAction is true', () => { + const file = createMockFile() + const { container } = render( + {}} />, + ) + const deleteButton = container.querySelector('button') + expect(deleteButton).toBeInTheDocument() + }) + + it('should not show delete button when showDeleteAction is false', () => { + const file = createMockFile() + const { container } = render() + const deleteButton = container.querySelector('button') + expect(deleteButton).not.toBeInTheDocument() + }) + }) + + describe('Progress States', () => { + it('should show progress indicator when uploading', () => { + const file = createMockFile({ progress: 50, uploadedId: undefined }) + const { container } = render() + expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument() + }) + + it('should not show progress indicator when upload is complete', () => { + const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' }) + const { container } = render() + expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument() + }) + + it('should show error overlay when progress is -1', () => { + const file = createMockFile({ progress: -1 }) + const { container } = render() + expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onPreview when clicked', () => { + const onPreview = vi.fn() + const file = createMockFile() + const { container } = render() + + const imageContainer = container.querySelector('.group\\/file-image') + if (imageContainer) { + fireEvent.click(imageContainer) + expect(onPreview).toHaveBeenCalledWith('test-id') + } + }) + + it('should call onRemove when delete button is clicked', () => { + const onRemove = vi.fn() + const file = createMockFile() + const { container } = render( + , + ) + + const deleteButton = container.querySelector('button') + if (deleteButton) { + fireEvent.click(deleteButton) + expect(onRemove).toHaveBeenCalledWith('test-id') + } + }) + + it('should call onReUpload when error overlay is clicked', () => { + const onReUpload = vi.fn() + const file = createMockFile({ progress: -1 }) + const { container } = render() + + const errorOverlay = container.querySelector('.bg-background-overlay-destructive') + if (errorOverlay) { + fireEvent.click(errorOverlay) + expect(onReUpload).toHaveBeenCalledWith('test-id') + } + }) + + it('should stop propagation on delete click', () => { + const onRemove = vi.fn() + const onPreview = vi.fn() + const file = createMockFile() + const { container } = render( + , + ) + + const deleteButton = container.querySelector('button') + if (deleteButton) { + fireEvent.click(deleteButton) + expect(onRemove).toHaveBeenCalled() + expect(onPreview).not.toHaveBeenCalled() + } + }) + }) + + describe('Edge Cases', () => { + it('should handle missing callbacks', () => { + const file = createMockFile() + const { container } = render() + + expect(() => { + const imageContainer = container.querySelector('.group\\/file-image') + if (imageContainer) + fireEvent.click(imageContainer) + }).not.toThrow() + }) + + it('should use base64Url when available', () => { + const file = createMockFile({ base64Url: 'data:custom' }) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should fallback to sourceUrl', () => { + const file = createMockFile({ base64Url: undefined }) + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.spec.tsx new file mode 100644 index 0000000000..6f168491af --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.spec.tsx @@ -0,0 +1,238 @@ +import type { FileEntity } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ImageUploaderInRetrievalTestingWrapper from './index' + +// Mock dependencies +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), +})) + +vi.mock('@/app/components/datasets/common/image-previewer', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +describe('ImageUploaderInRetrievalTesting', () => { + const defaultProps = { + textArea: