diff --git a/api/core/app/apps/base_app_queue_manager.py b/api/core/app/apps/base_app_queue_manager.py index 971ece6214..d2f09a25c3 100644 --- a/api/core/app/apps/base_app_queue_manager.py +++ b/api/core/app/apps/base_app_queue_manager.py @@ -133,7 +133,7 @@ class AppQueueManager(ABC): self._publish(event, pub_from) @abstractmethod - def _publish(self, event: AppQueueEvent, pub_from: PublishFrom): + def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: """ Publish event to queue :param event: diff --git a/api/core/moderation/base.py b/api/core/moderation/base.py index d76b4689be..31dd0d5568 100644 --- a/api/core/moderation/base.py +++ b/api/core/moderation/base.py @@ -39,7 +39,7 @@ class Moderation(Extensible, ABC): @classmethod @abstractmethod - def validate_config(cls, tenant_id: str, config: dict): + def validate_config(cls, tenant_id: str, config: dict) -> None: """ Validate the incoming form config data. diff --git a/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py b/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py index 312c7d3676..76755bf769 100644 --- a/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py +++ b/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py @@ -129,11 +129,11 @@ class LangfuseSpan(BaseModel): default=None, description="The id of the user that triggered the execution. Used to provide user-level analytics.", ) - start_time: datetime | str | None = Field( + start_time: datetime | None = Field( default_factory=datetime.now, description="The time at which the span started, defaults to the current time.", ) - end_time: datetime | str | None = Field( + end_time: datetime | None = Field( default=None, description="The time at which the span ended. Automatically set by span.end().", ) @@ -146,7 +146,7 @@ class LangfuseSpan(BaseModel): description="Additional metadata of the span. Can be any JSON object. Metadata is merged when being updated " "via the API.", ) - level: str | None = Field( + level: LevelEnum | None = Field( default=None, description="The level of the span. Can be DEBUG, DEFAULT, WARNING or ERROR. Used for sorting/filtering of " "traces with elevated error levels and for highlighting in the UI.", @@ -222,16 +222,16 @@ class LangfuseGeneration(BaseModel): default=None, description="Identifier of the generation. Useful for sorting/filtering in the UI.", ) - start_time: datetime | str | None = Field( + start_time: datetime | None = Field( default_factory=datetime.now, description="The time at which the generation started, defaults to the current time.", ) - completion_start_time: datetime | str | None = Field( + completion_start_time: datetime | None = Field( default=None, description="The time at which the completion started (streaming). Set it to get latency analytics broken " "down into time until completion started and completion duration.", ) - end_time: datetime | str | None = Field( + end_time: datetime | None = Field( default=None, description="The time at which the generation ended. Automatically set by generation.end().", ) diff --git a/api/core/ops/tencent_trace/client.py b/api/core/ops/tencent_trace/client.py index bf1ab5e7e6..99ccf00400 100644 --- a/api/core/ops/tencent_trace/client.py +++ b/api/core/ops/tencent_trace/client.py @@ -18,8 +18,7 @@ except ImportError: from importlib_metadata import version # type: ignore[import-not-found] if TYPE_CHECKING: - from opentelemetry.metrics import Meter - from opentelemetry.metrics._internal.instrument import Histogram + from opentelemetry.metrics import Histogram, Meter from opentelemetry.sdk.metrics.export import MetricReader from opentelemetry import trace as trace_api diff --git a/api/core/rag/datasource/vdb/vector_base.py b/api/core/rag/datasource/vdb/vector_base.py index 469978224a..f29b270e40 100644 --- a/api/core/rag/datasource/vdb/vector_base.py +++ b/api/core/rag/datasource/vdb/vector_base.py @@ -15,11 +15,11 @@ class BaseVector(ABC): raise NotImplementedError @abstractmethod - def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): + def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs) -> list[str] | None: raise NotImplementedError @abstractmethod - def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): + def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs) -> list[str]: raise NotImplementedError @abstractmethod @@ -27,14 +27,14 @@ class BaseVector(ABC): raise NotImplementedError @abstractmethod - def delete_by_ids(self, ids: list[str]): + def delete_by_ids(self, ids: list[str]) -> None: raise NotImplementedError def get_ids_by_metadata_field(self, key: str, value: str): raise NotImplementedError @abstractmethod - def delete_by_metadata_field(self, key: str, value: str): + def delete_by_metadata_field(self, key: str, value: str) -> None: raise NotImplementedError @abstractmethod @@ -46,7 +46,7 @@ class BaseVector(ABC): raise NotImplementedError @abstractmethod - def delete(self): + def delete(self) -> None: raise NotImplementedError def _filter_duplicate_texts(self, texts: list[Document]) -> list[Document]: diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py index 584975de05..67079665e6 100644 --- a/api/core/tools/utils/parser.py +++ b/api/core/tools/utils/parser.py @@ -2,7 +2,7 @@ import re from json import dumps as json_dumps from json import loads as json_loads from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, TypedDict import httpx from flask import request @@ -14,6 +14,12 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParamet from core.tools.errors import ToolApiSchemaError, ToolNotSupportedError, ToolProviderNotFoundError +class _OpenAPIInterface(TypedDict): + path: str + method: str + operation: dict[str, Any] + + class ApiBasedToolSchemaParser: @staticmethod def parse_openapi_to_tool_bundle( @@ -35,17 +41,17 @@ class ApiBasedToolSchemaParser: server_url = matched_servers[0] if matched_servers else server_url # list all interfaces - interfaces = [] + interfaces: list[_OpenAPIInterface] = [] for path, path_item in openapi["paths"].items(): methods = ["get", "post", "put", "delete", "patch", "head", "options", "trace"] for method in methods: if method in path_item: interfaces.append( - { - "path": path, - "method": method, - "operation": path_item[method], - } + _OpenAPIInterface( + path=path, + method=method, + operation=path_item[method], + ) ) # get all parameters diff --git a/api/tests/test_containers_integration_tests/models/test_dataset_models.py b/api/tests/test_containers_integration_tests/models/test_dataset_models.py new file mode 100644 index 0000000000..d2c3e1e58e --- /dev/null +++ b/api/tests/test_containers_integration_tests/models/test_dataset_models.py @@ -0,0 +1,271 @@ +""" +Integration tests for Dataset and Document model properties using testcontainers. + +These tests validate database-backed model properties (total_documents, word_count, etc.) +without mocking SQLAlchemy queries, ensuring real query behavior against PostgreSQL. +""" + +from collections.abc import Generator +from uuid import uuid4 + +import pytest +from sqlalchemy.orm import Session + +from models.dataset import Dataset, Document, DocumentSegment + + +class TestDatasetDocumentProperties: + """Integration tests for Dataset and Document model properties.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + """Automatically rollback session changes after each test.""" + yield + db_session_with_containers.rollback() + + def test_dataset_with_documents_relationship(self, db_session_with_containers: Session) -> None: + """Test dataset can track its documents.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + dataset = Dataset( + tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by + ) + db_session_with_containers.add(dataset) + db_session_with_containers.flush() + + for i in range(3): + doc = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=i + 1, + data_source_type="upload_file", + batch="batch_001", + name=f"doc_{i}.pdf", + created_from="web", + created_by=created_by, + ) + db_session_with_containers.add(doc) + db_session_with_containers.flush() + + assert dataset.total_documents == 3 + + def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None: + """Test dataset can count available documents.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + dataset = Dataset( + tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by + ) + db_session_with_containers.add(dataset) + db_session_with_containers.flush() + + doc_available = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=1, + data_source_type="upload_file", + batch="batch_001", + name="available.pdf", + created_from="web", + created_by=created_by, + indexing_status="completed", + enabled=True, + archived=False, + ) + doc_pending = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=2, + data_source_type="upload_file", + batch="batch_001", + name="pending.pdf", + created_from="web", + created_by=created_by, + indexing_status="waiting", + enabled=True, + archived=False, + ) + doc_disabled = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=3, + data_source_type="upload_file", + batch="batch_001", + name="disabled.pdf", + created_from="web", + created_by=created_by, + indexing_status="completed", + enabled=False, + archived=False, + ) + db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled]) + db_session_with_containers.flush() + + assert dataset.total_available_documents == 1 + + def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None: + """Test dataset can aggregate word count from documents.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + dataset = Dataset( + tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by + ) + db_session_with_containers.add(dataset) + db_session_with_containers.flush() + + for i, wc in enumerate([2000, 3000]): + doc = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=i + 1, + data_source_type="upload_file", + batch="batch_001", + name=f"doc_{i}.pdf", + created_from="web", + created_by=created_by, + word_count=wc, + ) + db_session_with_containers.add(doc) + db_session_with_containers.flush() + + assert dataset.word_count == 5000 + + def test_dataset_available_segment_count(self, db_session_with_containers: Session) -> None: + """Test Dataset.available_segment_count counts completed and enabled segments.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + dataset = Dataset( + tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by + ) + db_session_with_containers.add(dataset) + db_session_with_containers.flush() + + doc = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=1, + data_source_type="upload_file", + batch="batch_001", + name="doc.pdf", + created_from="web", + created_by=created_by, + ) + db_session_with_containers.add(doc) + db_session_with_containers.flush() + + for i in range(2): + seg = DocumentSegment( + tenant_id=tenant_id, + dataset_id=dataset.id, + document_id=doc.id, + position=i + 1, + content=f"segment {i}", + word_count=100, + tokens=50, + status="completed", + enabled=True, + created_by=created_by, + ) + db_session_with_containers.add(seg) + + seg_waiting = DocumentSegment( + tenant_id=tenant_id, + dataset_id=dataset.id, + document_id=doc.id, + position=3, + content="waiting segment", + word_count=100, + tokens=50, + status="waiting", + enabled=True, + created_by=created_by, + ) + db_session_with_containers.add(seg_waiting) + db_session_with_containers.flush() + + assert dataset.available_segment_count == 2 + + def test_document_segment_count_property(self, db_session_with_containers: Session) -> None: + """Test document can count its segments.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + dataset = Dataset( + tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by + ) + db_session_with_containers.add(dataset) + db_session_with_containers.flush() + + doc = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=1, + data_source_type="upload_file", + batch="batch_001", + name="doc.pdf", + created_from="web", + created_by=created_by, + ) + db_session_with_containers.add(doc) + db_session_with_containers.flush() + + for i in range(3): + seg = DocumentSegment( + tenant_id=tenant_id, + dataset_id=dataset.id, + document_id=doc.id, + position=i + 1, + content=f"segment {i}", + word_count=100, + tokens=50, + created_by=created_by, + ) + db_session_with_containers.add(seg) + db_session_with_containers.flush() + + assert doc.segment_count == 3 + + def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None: + """Test document can aggregate hit count from segments.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + dataset = Dataset( + tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by + ) + db_session_with_containers.add(dataset) + db_session_with_containers.flush() + + doc = Document( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=1, + data_source_type="upload_file", + batch="batch_001", + name="doc.pdf", + created_from="web", + created_by=created_by, + ) + db_session_with_containers.add(doc) + db_session_with_containers.flush() + + for i, hits in enumerate([10, 15]): + seg = DocumentSegment( + tenant_id=tenant_id, + dataset_id=dataset.id, + document_id=doc.id, + position=i + 1, + content=f"segment {i}", + word_count=100, + tokens=50, + hit_count=hits, + created_by=created_by, + ) + db_session_with_containers.add(seg) + db_session_with_containers.flush() + + assert doc.hit_count == 25 diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 2322c556e2..c0e912fa1e 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -12,7 +12,7 @@ This test suite covers: import json import pickle from datetime import UTC, datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch from uuid import uuid4 from models.dataset import ( @@ -954,156 +954,6 @@ class TestChildChunk: assert child_chunk.index_node_hash == index_node_hash -class TestDatasetDocumentCascadeDeletes: - """Test suite for Dataset-Document cascade delete operations.""" - - def test_dataset_with_documents_relationship(self): - """Test dataset can track its documents.""" - # Arrange - dataset_id = str(uuid4()) - dataset = Dataset( - tenant_id=str(uuid4()), - name="Test Dataset", - data_source_type="upload_file", - created_by=str(uuid4()), - ) - dataset.id = dataset_id - - # Mock the database session query - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = 3 - - with patch("models.dataset.db.session.query", return_value=mock_query): - # Act - total_docs = dataset.total_documents - - # Assert - assert total_docs == 3 - - def test_dataset_available_documents_count(self): - """Test dataset can count available documents.""" - # Arrange - dataset_id = str(uuid4()) - dataset = Dataset( - tenant_id=str(uuid4()), - name="Test Dataset", - data_source_type="upload_file", - created_by=str(uuid4()), - ) - dataset.id = dataset_id - - # Mock the database session query - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = 2 - - with patch("models.dataset.db.session.query", return_value=mock_query): - # Act - available_docs = dataset.total_available_documents - - # Assert - assert available_docs == 2 - - def test_dataset_word_count_aggregation(self): - """Test dataset can aggregate word count from documents.""" - # Arrange - dataset_id = str(uuid4()) - dataset = Dataset( - tenant_id=str(uuid4()), - name="Test Dataset", - data_source_type="upload_file", - created_by=str(uuid4()), - ) - dataset.id = dataset_id - - # Mock the database session query - mock_query = MagicMock() - mock_query.with_entities.return_value.where.return_value.scalar.return_value = 5000 - - with patch("models.dataset.db.session.query", return_value=mock_query): - # Act - total_words = dataset.word_count - - # Assert - assert total_words == 5000 - - def test_dataset_available_segment_count(self): - """Test dataset can count available segments.""" - # Arrange - dataset_id = str(uuid4()) - dataset = Dataset( - tenant_id=str(uuid4()), - name="Test Dataset", - data_source_type="upload_file", - created_by=str(uuid4()), - ) - dataset.id = dataset_id - - # Mock the database session query - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = 15 - - with patch("models.dataset.db.session.query", return_value=mock_query): - # Act - segment_count = dataset.available_segment_count - - # Assert - assert segment_count == 15 - - def test_document_segment_count_property(self): - """Test document can count its segments.""" - # Arrange - document_id = str(uuid4()) - document = Document( - tenant_id=str(uuid4()), - dataset_id=str(uuid4()), - position=1, - data_source_type="upload_file", - batch="batch_001", - name="test.pdf", - created_from="web", - created_by=str(uuid4()), - ) - document.id = document_id - - # Mock the database session query - mock_query = MagicMock() - mock_query.where.return_value.count.return_value = 10 - - with patch("models.dataset.db.session.query", return_value=mock_query): - # Act - segment_count = document.segment_count - - # Assert - assert segment_count == 10 - - def test_document_hit_count_aggregation(self): - """Test document can aggregate hit count from segments.""" - # Arrange - document_id = str(uuid4()) - document = Document( - tenant_id=str(uuid4()), - dataset_id=str(uuid4()), - position=1, - data_source_type="upload_file", - batch="batch_001", - name="test.pdf", - created_from="web", - created_by=str(uuid4()), - ) - document.id = document_id - - # Mock the database session query - mock_query = MagicMock() - mock_query.with_entities.return_value.where.return_value.scalar.return_value = 25 - - with patch("models.dataset.db.session.query", return_value=mock_query): - # Act - hit_count = document.hit_count - - # Assert - assert hit_count == 25 - - class TestDocumentSegmentNavigation: """Test suite for DocumentSegment navigation properties.""" diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/index.spec.tsx index 490d7b4410..096358c805 100644 --- a/web/app/components/app/configuration/config-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/index.spec.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import type { IConfigVarProps } from './index' import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' import { vi } from 'vitest' import Toast from '@/app/components/base/toast' @@ -237,7 +237,8 @@ describe('ConfigVar', () => { expect(actionButtons).toHaveLength(2) fireEvent.click(actionButtons[0]) - const saveButton = await screen.findByRole('button', { name: 'common.operation.save' }) + const editDialog = await screen.findByRole('dialog') + const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' }) fireEvent.click(saveButton) await waitFor(() => { diff --git a/web/app/components/app/overview/customize/index.spec.tsx b/web/app/components/app/overview/customize/index.spec.tsx index e1bb7e938d..fab78347d0 100644 --- a/web/app/components/app/overview/customize/index.spec.tsx +++ b/web/app/components/app/overview/customize/index.spec.tsx @@ -323,14 +323,8 @@ describe('CustomizeModal', () => { expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument() }) - // Find the close button by navigating from the heading to the close icon - // The close icon is an SVG inside a sibling div of the title - const heading = screen.getByRole('heading', { name: /customize\.title/i }) - const closeIcon = heading.parentElement!.querySelector('svg') - - // Assert - closeIcon must exist for the test to be valid - expect(closeIcon).toBeInTheDocument() - fireEvent.click(closeIcon!) + const closeButton = screen.getByTestId('modal-close-button') + fireEvent.click(closeButton) expect(onClose).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/base/alert.spec.tsx b/web/app/components/base/alert.spec.tsx new file mode 100644 index 0000000000..1ad52ea201 --- /dev/null +++ b/web/app/components/base/alert.spec.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Alert from './alert' + +describe('Alert', () => { + const defaultProps = { + message: 'This is an alert message', + onHide: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(defaultProps.message)).toBeInTheDocument() + }) + + it('should render the info icon', () => { + render() + const icon = screen.getByTestId('info-icon') + expect(icon).toBeInTheDocument() + }) + + it('should render the close icon', () => { + render() + const closeIcon = screen.getByTestId('close-icon') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('my-custom-class') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pointer-events-none', 'w-full') + }) + + it('should default type to info', () => { + render() + const gradientDiv = screen.getByTestId('alert-gradient') + expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo') + }) + + it('should render with explicit type info', () => { + render() + const gradientDiv = screen.getByTestId('alert-gradient') + expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo') + }) + + it('should display the provided message text', () => { + const msg = 'A different alert message' + render() + expect(screen.getByText(msg)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onHide when close button is clicked', () => { + const onHide = vi.fn() + render() + const closeButton = screen.getByTestId('close-icon') + fireEvent.click(closeButton) + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should not call onHide when other parts of the alert are clicked', () => { + const onHide = vi.fn() + render() + fireEvent.click(screen.getByText(defaultProps.message)) + expect(onHide).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should render with an empty message string', () => { + render() + const messageDiv = screen.getByTestId('msg-container') + expect(messageDiv).toBeInTheDocument() + expect(messageDiv).toHaveTextContent('') + }) + + it('should render with a very long message', () => { + const longMessage = 'A'.repeat(1000) + render() + expect(screen.getByText(longMessage)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/alert.tsx b/web/app/components/base/alert.tsx index cf602b541a..3c1671bb2c 100644 --- a/web/app/components/base/alert.tsx +++ b/web/app/components/base/alert.tsx @@ -1,7 +1,3 @@ -import { - RiCloseLine, - RiInformation2Fill, -} from '@remixicon/react' import { cva } from 'class-variance-authority' import { memo, @@ -35,13 +31,13 @@ const Alert: React.FC = ({
-
+
- +
-
+
{message}
@@ -49,7 +45,7 @@ const Alert: React.FC = ({ className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center" onClick={onHide} > - +
diff --git a/web/app/components/base/app-icon-picker/ImageInput.spec.tsx b/web/app/components/base/app-icon-picker/ImageInput.spec.tsx new file mode 100644 index 0000000000..8e0476823a --- /dev/null +++ b/web/app/components/base/app-icon-picker/ImageInput.spec.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import ImageInput from './ImageInput' + +const createObjectURLMock = vi.fn(() => 'blob:mock-url') +const revokeObjectURLMock = vi.fn() +const originalCreateObjectURL = globalThis.URL.createObjectURL +const originalRevokeObjectURL = globalThis.URL.revokeObjectURL + +const waitForCropperContainer = async () => { + await waitFor(() => { + expect(screen.getByTestId('container')).toBeInTheDocument() + }) +} + +const loadCropperImage = async () => { + await waitForCropperContainer() + const cropperImage = screen.getByTestId('container').querySelector('img') + if (!cropperImage) + throw new Error('Could not find cropper image') + + fireEvent.load(cropperImage) +} + +describe('ImageInput', () => { + beforeEach(() => { + vi.clearAllMocks() + globalThis.URL.createObjectURL = createObjectURLMock + globalThis.URL.revokeObjectURL = revokeObjectURLMock + }) + + afterEach(() => { + globalThis.URL.createObjectURL = originalCreateObjectURL + globalThis.URL.revokeObjectURL = originalRevokeObjectURL + }) + + describe('Rendering', () => { + it('should render upload prompt when no image is selected', () => { + render() + + expect(screen.getByText(/drop.*here/i)).toBeInTheDocument() + expect(screen.getByText(/browse/i)).toBeInTheDocument() + expect(screen.getByText(/supported/i)).toBeInTheDocument() + }) + + it('should render a hidden file input', () => { + render() + + const input = screen.getByTestId('image-input') + expect(input).toBeInTheDocument() + expect(input).toHaveClass('hidden') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('my-custom-class') + }) + }) + + describe('User Interactions', () => { + it('should trigger file input click when browse button is clicked', () => { + render() + + const fileInput = screen.getByTestId('image-input') + const clickSpy = vi.spyOn(fileInput, 'click') + + fireEvent.click(screen.getByText(/browse/i)) + + expect(clickSpy).toHaveBeenCalled() + }) + + it('should show Cropper when a static image file is selected', async () => { + render() + + const file = new File(['image-data'], 'photo.png', { type: 'image/png' }) + const input = screen.getByTestId('image-input') + fireEvent.change(input, { target: { files: [file] } }) + + await waitForCropperContainer() + + // Upload prompt should be gone + expect(screen.queryByText(/browse/i)).not.toBeInTheDocument() + }) + + it('should call onImageInput with cropped data when crop completes on static image', async () => { + const onImageInput = vi.fn() + render() + + const file = new File(['image-data'], 'photo.png', { type: 'image/png' }) + const input = screen.getByTestId('image-input') + fireEvent.change(input, { target: { files: [file] } }) + + await loadCropperImage() + + await waitFor(() => { + expect(onImageInput).toHaveBeenCalledWith( + true, + 'blob:mock-url', + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }), + 'photo.png', + ) + }) + }) + + it('should show img tag and call onImageInput with isCropped=false for animated GIF', async () => { + const onImageInput = vi.fn() + render() + + const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + const file = new File([gifBytes], 'anim.gif', { type: 'image/gif' }) + const input = screen.getByTestId('image-input') + fireEvent.change(input, { target: { files: [file] } }) + + await waitFor(() => { + const img = screen.queryByTestId('animated-image') as HTMLImageElement + expect(img).toBeInTheDocument() + expect(img?.src).toContain('blob:mock-url') + }) + + // Cropper should NOT be shown + expect(screen.queryByTestId('container')).not.toBeInTheDocument() + expect(onImageInput).toHaveBeenCalledWith(false, file) + }) + + it('should not crash when file input has no files', () => { + render() + + const input = screen.getByTestId('image-input') + fireEvent.change(input, { target: { files: null } }) + + // Should still show upload prompt + expect(screen.getByText(/browse/i)).toBeInTheDocument() + }) + + it('should reset file input value on click', () => { + render() + + const input = screen.getByTestId('image-input') as HTMLInputElement + // Simulate previous value + Object.defineProperty(input, 'value', { writable: true, value: 'old-file.png' }) + fireEvent.click(input) + expect(input.value).toBe('') + }) + }) + + describe('Drag and Drop', () => { + it('should apply active border class on drag enter', () => { + render() + + const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement + + fireEvent.dragEnter(dropZone) + expect(dropZone).toHaveClass('border-primary-600') + }) + + it('should remove active border class on drag leave', () => { + render() + + const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement + + fireEvent.dragEnter(dropZone) + expect(dropZone).toHaveClass('border-primary-600') + + fireEvent.dragLeave(dropZone) + expect(dropZone).not.toHaveClass('border-primary-600') + }) + + it('should show image after dropping a file', async () => { + render() + + const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement + const file = new File(['image-data'], 'dropped.png', { type: 'image/png' }) + + fireEvent.drop(dropZone, { + dataTransfer: { files: [file] }, + }) + + await waitForCropperContainer() + }) + }) + + describe('Cleanup', () => { + it('should call URL.revokeObjectURL on unmount when an image was set', async () => { + const { unmount } = render() + + const file = new File(['image-data'], 'photo.png', { type: 'image/png' }) + const input = screen.getByTestId('image-input') + fireEvent.change(input, { target: { files: [file] } }) + + await waitForCropperContainer() + + unmount() + + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url') + }) + + it('should not call URL.revokeObjectURL on unmount when no image was set', () => { + const { unmount } = render() + unmount() + expect(revokeObjectURLMock).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should not crash when onImageInput is not provided', async () => { + render() + + const file = new File(['image-data'], 'photo.png', { type: 'image/png' }) + const input = screen.getByTestId('image-input') + + // Should not throw + fireEvent.change(input, { target: { files: [file] } }) + + await loadCropperImage() + await waitFor(() => { + expect(screen.getByTestId('cropper')).toBeInTheDocument() + }) + }) + + it('should accept the correct file extensions', () => { + render() + + const input = screen.getByTestId('image-input') as HTMLInputElement + expect(input.accept).toContain('.png') + expect(input.accept).toContain('.jpg') + expect(input.accept).toContain('.jpeg') + expect(input.accept).toContain('.webp') + expect(input.accept).toContain('.gif') + }) + }) +}) diff --git a/web/app/components/base/app-icon-picker/ImageInput.tsx b/web/app/components/base/app-icon-picker/ImageInput.tsx index d41f3bf232..e255b2cfe6 100644 --- a/web/app/components/base/app-icon-picker/ImageInput.tsx +++ b/web/app/components/base/app-icon-picker/ImageInput.tsx @@ -72,7 +72,8 @@ const ImageInput: FC = ({ const handleShowImage = () => { if (isAnimatedImage) { return ( - + // eslint-disable-next-line next/no-img-element + ) } @@ -107,7 +108,7 @@ const ImageInput: FC = ({
{t('imageInput.dropImageHere', { ns: 'common' })} -  +   = ({ onClick={e => ((e.target as HTMLInputElement).value = '')} accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')} onChange={handleLocalFileInput} + data-testid="image-input" />
{t('imageInput.supportedFormats', { ns: 'common' })}
diff --git a/web/app/components/base/app-icon-picker/hooks.spec.tsx b/web/app/components/base/app-icon-picker/hooks.spec.tsx new file mode 100644 index 0000000000..58741a3ecf --- /dev/null +++ b/web/app/components/base/app-icon-picker/hooks.spec.tsx @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react' +import { useDraggableUploader } from './hooks' + +type MockDragEventOverrides = { + dataTransfer?: { files: File[] } +} + +const createDragEvent = (overrides: MockDragEventOverrides = {}): React.DragEvent => ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { files: [] as unknown as FileList }, + ...overrides, +} as unknown as React.DragEvent) + +describe('useDraggableUploader', () => { + let setImageFn: ReturnType void>> + + beforeEach(() => { + vi.clearAllMocks() + setImageFn = vi.fn<(file: File) => void>() + }) + + describe('Rendering', () => { + it('should return all expected handler functions and isDragActive state', () => { + const { result } = renderHook(() => useDraggableUploader(setImageFn)) + + expect(result.current.handleDragEnter).toBeInstanceOf(Function) + expect(result.current.handleDragOver).toBeInstanceOf(Function) + expect(result.current.handleDragLeave).toBeInstanceOf(Function) + expect(result.current.handleDrop).toBeInstanceOf(Function) + expect(result.current.isDragActive).toBe(false) + }) + }) + + describe('Drag Events', () => { + it('should set isDragActive to true on drag enter', () => { + const { result } = renderHook(() => useDraggableUploader(setImageFn)) + const event = createDragEvent() + + act(() => { + result.current.handleDragEnter(event) + }) + + expect(result.current.isDragActive).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + }) + + it('should call preventDefault and stopPropagation on drag over without changing isDragActive', () => { + const { result } = renderHook(() => useDraggableUploader(setImageFn)) + const event = createDragEvent() + + act(() => { + result.current.handleDragOver(event) + }) + + expect(result.current.isDragActive).toBe(false) + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + }) + + it('should set isDragActive to false on drag leave', () => { + const { result } = renderHook(() => useDraggableUploader(setImageFn)) + const enterEvent = createDragEvent() + const leaveEvent = createDragEvent() + + act(() => { + result.current.handleDragEnter(enterEvent) + }) + expect(result.current.isDragActive).toBe(true) + + act(() => { + result.current.handleDragLeave(leaveEvent) + }) + + expect(result.current.isDragActive).toBe(false) + expect(leaveEvent.preventDefault).toHaveBeenCalled() + expect(leaveEvent.stopPropagation).toHaveBeenCalled() + }) + }) + + describe('Drop', () => { + it('should call setImageFn with the dropped file and set isDragActive to false', () => { + const { result } = renderHook(() => useDraggableUploader(setImageFn)) + const file = new File(['test'], 'image.png', { type: 'image/png' }) + const event = createDragEvent({ + dataTransfer: { files: [file] }, + }) + + // First set isDragActive to true + act(() => { + result.current.handleDragEnter(createDragEvent()) + }) + expect(result.current.isDragActive).toBe(true) + + act(() => { + result.current.handleDrop(event) + }) + + expect(result.current.isDragActive).toBe(false) + expect(setImageFn).toHaveBeenCalledWith(file) + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + }) + + it('should not call setImageFn when no file is dropped', () => { + const { result } = renderHook(() => useDraggableUploader(setImageFn)) + const event = createDragEvent({ + dataTransfer: { files: [] }, + }) + + act(() => { + result.current.handleDrop(event) + }) + + expect(setImageFn).not.toHaveBeenCalled() + expect(result.current.isDragActive).toBe(false) + }) + }) +}) diff --git a/web/app/components/base/app-icon-picker/index.spec.tsx b/web/app/components/base/app-icon-picker/index.spec.tsx new file mode 100644 index 0000000000..63d447e289 --- /dev/null +++ b/web/app/components/base/app-icon-picker/index.spec.tsx @@ -0,0 +1,339 @@ +import type { Area } from 'react-easy-crop' +import type { ImageFile } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TransferMethod } from '@/types/app' +import AppIconPicker from './index' +import 'vitest-canvas-mock' + +type LocalFileUploaderOptions = { + disabled?: boolean + limit?: number + onUpload: (imageFile: ImageFile) => void +} + +class MockLoadedImage { + width = 320 + height = 160 + private listeners: Record = {} + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) + if (!this.listeners[type]) + this.listeners[type] = [] + this.listeners[type].push(eventListener) + } + + setAttribute(_name: string, _value: string) { } + + set src(_value: string) { + queueMicrotask(() => { + for (const listener of this.listeners.load ?? []) + listener(new Event('load')) + }) + } + + get src() { + return '' + } +} + +const createImageFile = (overrides: Partial = {}): ImageFile => ({ + type: TransferMethod.local_file, + _id: 'test-image-id', + fileId: 'uploaded-image-id', + progress: 100, + url: 'https://example.com/uploaded.png', + ...overrides, +}) + +const createCanvasContextMock = (): CanvasRenderingContext2D => + ({ + translate: vi.fn(), + rotate: vi.fn(), + scale: vi.fn(), + drawImage: vi.fn(), + }) as unknown as CanvasRenderingContext2D + +const createCanvasElementMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'], { type: 'image/png' })) => + ({ + width: 0, + height: 0, + getContext: vi.fn(() => context), + toBlob: vi.fn((callback: BlobCallback) => callback(blob)), + }) as unknown as HTMLCanvasElement + +const mocks = vi.hoisted(() => ({ + disableUpload: false, + uploadResult: null as ImageFile | null, + onUpload: null as ((imageFile: ImageFile) => void) | null, + handleLocalFileUpload: vi.fn<(file: File) => void>(), +})) + +vi.mock('@/config', () => ({ + get DISABLE_UPLOAD_IMAGE_AS_ICON() { + return mocks.disableUpload + }, +})) + +vi.mock('react-easy-crop', () => ({ + default: ({ onCropComplete }: { onCropComplete: (_area: Area, croppedAreaPixels: Area) => void }) => ( +
+ +
+ ), +})) + +vi.mock('../image-uploader/hooks', () => ({ + useLocalFileUploader: (options: LocalFileUploaderOptions) => { + mocks.onUpload = options.onUpload + return { handleLocalFileUpload: mocks.handleLocalFileUpload } + }, +})) + +vi.mock('@/utils/emoji', () => ({ + searchEmoji: vi.fn().mockResolvedValue(['grinning', 'sunglasses']), +})) + +describe('AppIconPicker', () => { + const originalCreateElement = document.createElement.bind(document) + const originalCreateObjectURL = globalThis.URL.createObjectURL + const originalRevokeObjectURL = globalThis.URL.revokeObjectURL + let originalImage: typeof Image + + const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => { + vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters) => { + if (args[0] === 'canvas') { + const nextCanvas = canvases.shift() + if (!nextCanvas) + throw new Error('Unexpected canvas creation') + return nextCanvas as ReturnType + } + return originalCreateElement(...args) + }) + } + + const renderPicker = () => { + const onSelect = vi.fn() + const onClose = vi.fn() + + const { container } = render() + + return { onSelect, onClose, container } + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.disableUpload = false + mocks.uploadResult = createImageFile() + mocks.onUpload = null + mocks.handleLocalFileUpload.mockImplementation(() => { + if (mocks.uploadResult) + mocks.onUpload?.(mocks.uploadResult) + }) + + originalImage = globalThis.Image + globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url') + globalThis.URL.revokeObjectURL = vi.fn() + }) + + afterEach(() => { + globalThis.Image = originalImage + globalThis.URL.createObjectURL = originalCreateObjectURL + globalThis.URL.revokeObjectURL = originalRevokeObjectURL + }) + + describe('Rendering', () => { + it('should render emoji and image tabs when upload is enabled', async () => { + renderPicker() + + expect(await screen.findByText(/emoji/i)).toBeInTheDocument() + expect(screen.getByText(/image/i)).toBeInTheDocument() + expect(screen.getByText(/cancel/i)).toBeInTheDocument() + expect(screen.getByText(/ok/i)).toBeInTheDocument() + }) + + it('should hide the image tab when upload is disabled', () => { + mocks.disableUpload = true + renderPicker() + + expect(screen.queryByText(/image/i)).not.toBeInTheDocument() + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onClose when cancel is clicked', async () => { + const { onClose } = renderPicker() + + await userEvent.click(screen.getByText(/cancel/i)) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should switch between emoji and image tabs', async () => { + renderPicker() + + await userEvent.click(screen.getByText(/image/i)) + expect(screen.getByText(/drop.*here/i)).toBeInTheDocument() + + await userEvent.click(screen.getByText(/emoji/i)) + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('should call onSelect with emoji data after emoji selection', async () => { + const { onSelect } = renderPicker() + + await waitFor(() => { + expect(screen.queryAllByTestId(/emoji-container-/i).length).toBeGreaterThan(0) + }) + + const firstEmoji = screen.queryAllByTestId(/emoji-container-/i)[0] + if (!firstEmoji) + throw new Error('Could not find emoji option') + + await userEvent.click(firstEmoji) + await userEvent.click(screen.getByText(/ok/i)) + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ + type: 'emoji', + icon: expect.any(String), + background: expect.any(String), + })) + }) + }) + + it('should not call onSelect when no emoji has been selected', async () => { + const { onSelect } = renderPicker() + + await userEvent.click(screen.getByText(/ok/i)) + + expect(onSelect).not.toHaveBeenCalled() + }) + }) + + describe('Image Upload', () => { + it('should return early when image tab is active and no file has been selected', async () => { + const { onSelect } = renderPicker() + + await userEvent.click(screen.getByText(/image/i)) + await userEvent.click(screen.getByText(/ok/i)) + + expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled() + expect(onSelect).not.toHaveBeenCalled() + }) + + it('should upload cropped static image and emit selected image metadata', async () => { + globalThis.Image = MockLoadedImage as unknown as typeof Image + + const sourceCanvas = createCanvasElementMock(createCanvasContextMock()) + const croppedBlob = new Blob(['cropped-image'], { type: 'image/png' }) + const croppedCanvas = createCanvasElementMock(createCanvasContextMock(), croppedBlob) + mockCanvasCreation([sourceCanvas, croppedCanvas]) + + const { onSelect } = renderPicker() + await userEvent.click(screen.getByText(/image/i)) + + const input = screen.queryByTestId('image-input') + if (!input) + throw new Error('Could not find image input') + + fireEvent.change(input, { target: { files: [new File(['png'], 'avatar.png', { type: 'image/png' })] } }) + + await waitFor(() => { + expect(screen.getByTestId('mock-cropper')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('trigger-crop')) + await userEvent.click(screen.getByText(/ok/i)) + + await waitFor(() => { + expect(mocks.handleLocalFileUpload).toHaveBeenCalledTimes(1) + }) + + const uploadedFile = mocks.handleLocalFileUpload.mock.calls[0][0] + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe('avatar.png') + expect(uploadedFile.type).toBe('image/png') + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith({ + type: 'image', + fileId: 'uploaded-image-id', + url: 'https://example.com/uploaded.png', + }) + }) + }) + + it('should upload animated image directly without crop', async () => { + const { onSelect } = renderPicker() + await userEvent.click(screen.getByText(/image/i)) + + const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + const gifFile = new File([gifBytes], 'animated.gif', { type: 'image/gif' }) + + const input = screen.queryByTestId('image-input') + if (!input) + throw new Error('Could not find image input') + + fireEvent.change(input, { target: { files: [gifFile] } }) + + await waitFor(() => { + expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument() + const preview = screen.queryByTestId('animated-image') + expect(preview).toBeInTheDocument() + expect(preview?.getAttribute('src')).toContain('blob:mock-url') + }) + + await userEvent.click(screen.getByText(/ok/i)) + + await waitFor(() => { + expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile) + }) + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith({ + type: 'image', + fileId: 'uploaded-image-id', + url: 'https://example.com/uploaded.png', + }) + }) + }) + + it('should not call onSelect when upload callback returns image without fileId', async () => { + mocks.uploadResult = createImageFile({ fileId: '' }) + const { onSelect } = renderPicker() + await userEvent.click(screen.getByText(/image/i)) + + const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + const gifFile = new File([gifBytes], 'no-file-id.gif', { type: 'image/gif' }) + + const input = screen.queryByTestId('image-input') + if (!input) + throw new Error('Could not find image input') + + fireEvent.change(input, { target: { files: [gifFile] } }) + + await waitFor(() => { + expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument() + }) + + await userEvent.click(screen.getByText(/ok/i)) + + await waitFor(() => { + expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile) + }) + expect(onSelect).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/app-icon-picker/utils.spec.ts b/web/app/components/base/app-icon-picker/utils.spec.ts new file mode 100644 index 0000000000..778d384910 --- /dev/null +++ b/web/app/components/base/app-icon-picker/utils.spec.ts @@ -0,0 +1,364 @@ +import getCroppedImg, { checkIsAnimatedImage, createImage, getMimeType, getRadianAngle, rotateSize } from './utils' + +type ImageLoadEventType = 'load' | 'error' + +class MockImageElement { + static nextEvent: ImageLoadEventType = 'load' + width = 320 + height = 160 + crossOriginValue = '' + srcValue = '' + private listeners: Record = {} + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) + if (!this.listeners[type]) + this.listeners[type] = [] + this.listeners[type].push(eventListener) + } + + setAttribute(name: string, value: string) { + if (name === 'crossOrigin') + this.crossOriginValue = value + } + + set src(value: string) { + this.srcValue = value + queueMicrotask(() => { + const event = new Event(MockImageElement.nextEvent) + for (const listener of this.listeners[MockImageElement.nextEvent] ?? []) + listener(event) + }) + } + + get src() { + return this.srcValue + } +} + +type CanvasMock = { + element: HTMLCanvasElement + getContextMock: ReturnType + toBlobMock: ReturnType +} + +const createCanvasMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'])): CanvasMock => { + const getContextMock = vi.fn(() => context) + const toBlobMock = vi.fn((callback: BlobCallback) => callback(blob)) + return { + element: { + width: 0, + height: 0, + getContext: getContextMock, + toBlob: toBlobMock, + } as unknown as HTMLCanvasElement, + getContextMock, + toBlobMock, + } +} + +const createCanvasContextMock = (): CanvasRenderingContext2D => + ({ + translate: vi.fn(), + rotate: vi.fn(), + scale: vi.fn(), + drawImage: vi.fn(), + }) as unknown as CanvasRenderingContext2D + +describe('utils', () => { + const originalCreateElement = document.createElement.bind(document) + let originalImage: typeof Image + + beforeEach(() => { + vi.clearAllMocks() + originalImage = globalThis.Image + MockImageElement.nextEvent = 'load' + }) + + afterEach(() => { + globalThis.Image = originalImage + vi.restoreAllMocks() + }) + + const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => { + vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters) => { + if (args[0] === 'canvas') { + const nextCanvas = canvases.shift() + if (!nextCanvas) + throw new Error('Unexpected canvas creation') + return nextCanvas as ReturnType + } + return originalCreateElement(...args) + }) + } + + describe('createImage', () => { + it('should resolve image when load event fires', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + + const image = await createImage('https://example.com/image.png') + const mockImage = image as unknown as MockImageElement + + expect(mockImage.crossOriginValue).toBe('anonymous') + expect(mockImage.src).toBe('https://example.com/image.png') + }) + + it('should reject when error event fires', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + MockImageElement.nextEvent = 'error' + + await expect(createImage('https://example.com/broken.png')).rejects.toBeInstanceOf(Event) + }) + }) + + describe('getMimeType', () => { + it('should return image/png for .png files', () => { + expect(getMimeType('photo.png')).toBe('image/png') + }) + + it('should return image/jpeg for .jpg files', () => { + expect(getMimeType('photo.jpg')).toBe('image/jpeg') + }) + + it('should return image/jpeg for .jpeg files', () => { + expect(getMimeType('photo.jpeg')).toBe('image/jpeg') + }) + + it('should return image/gif for .gif files', () => { + expect(getMimeType('animation.gif')).toBe('image/gif') + }) + + it('should return image/webp for .webp files', () => { + expect(getMimeType('photo.webp')).toBe('image/webp') + }) + + it('should return image/jpeg as default for unknown extensions', () => { + expect(getMimeType('file.bmp')).toBe('image/jpeg') + }) + + it('should return image/jpeg for files with no extension', () => { + expect(getMimeType('file')).toBe('image/jpeg') + }) + + it('should handle uppercase extensions via toLowerCase', () => { + expect(getMimeType('photo.PNG')).toBe('image/png') + }) + }) + + describe('getRadianAngle', () => { + it('should return 0 for 0 degrees', () => { + expect(getRadianAngle(0)).toBe(0) + }) + + it('should return PI/2 for 90 degrees', () => { + expect(getRadianAngle(90)).toBeCloseTo(Math.PI / 2) + }) + + it('should return PI for 180 degrees', () => { + expect(getRadianAngle(180)).toBeCloseTo(Math.PI) + }) + + it('should return 2*PI for 360 degrees', () => { + expect(getRadianAngle(360)).toBeCloseTo(2 * Math.PI) + }) + + it('should handle negative angles', () => { + expect(getRadianAngle(-90)).toBeCloseTo(-Math.PI / 2) + }) + }) + + describe('rotateSize', () => { + it('should return same dimensions for 0 degree rotation', () => { + const result = rotateSize(100, 200, 0) + expect(result.width).toBeCloseTo(100) + expect(result.height).toBeCloseTo(200) + }) + + it('should swap dimensions for 90 degree rotation', () => { + const result = rotateSize(100, 200, 90) + expect(result.width).toBeCloseTo(200) + expect(result.height).toBeCloseTo(100) + }) + + it('should return same dimensions for 180 degree rotation', () => { + const result = rotateSize(100, 200, 180) + expect(result.width).toBeCloseTo(100) + expect(result.height).toBeCloseTo(200) + }) + + it('should handle square dimensions', () => { + const result = rotateSize(100, 100, 45) + // 45° rotation of a square produces a larger bounding box + const expected = Math.abs(Math.cos(Math.PI / 4) * 100) + Math.abs(Math.sin(Math.PI / 4) * 100) + expect(result.width).toBeCloseTo(expected) + expect(result.height).toBeCloseTo(expected) + }) + }) + + describe('getCroppedImg', () => { + it('should return a blob when canvas operations succeed', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + + const sourceContext = createCanvasContextMock() + const croppedContext = createCanvasContextMock() + const sourceCanvas = createCanvasMock(sourceContext) + const expectedBlob = new Blob(['cropped'], { type: 'image/webp' }) + const croppedCanvas = createCanvasMock(croppedContext, expectedBlob) + mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) + + const result = await getCroppedImg( + 'https://example.com/image.webp', + { x: 10, y: 20, width: 50, height: 40 }, + 'avatar.webp', + 90, + { horizontal: true, vertical: false }, + ) + + expect(result).toBe(expectedBlob) + expect(croppedCanvas.toBlobMock).toHaveBeenCalledWith(expect.any(Function), 'image/webp') + expect(sourceContext.translate).toHaveBeenCalled() + expect(sourceContext.rotate).toHaveBeenCalled() + expect(sourceContext.scale).toHaveBeenCalledWith(-1, 1) + expect(croppedContext.drawImage).toHaveBeenCalled() + }) + + it('should apply vertical flip when vertical option is true', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + + const sourceContext = createCanvasContextMock() + const croppedContext = createCanvasContextMock() + const sourceCanvas = createCanvasMock(sourceContext) + const croppedCanvas = createCanvasMock(croppedContext) + mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) + + await getCroppedImg( + 'https://example.com/image.png', + { x: 0, y: 0, width: 20, height: 20 }, + 'avatar.png', + 0, + { horizontal: false, vertical: true }, + ) + + expect(sourceContext.scale).toHaveBeenCalledWith(1, -1) + }) + + it('should throw when source canvas context is unavailable', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + + const sourceCanvas = createCanvasMock(null) + mockCanvasCreation([sourceCanvas.element]) + + await expect( + getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'), + ).rejects.toThrow('Could not create a canvas context') + }) + + it('should throw when cropped canvas context is unavailable', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + + const sourceCanvas = createCanvasMock(createCanvasContextMock()) + const croppedCanvas = createCanvasMock(null) + mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) + + await expect( + getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'), + ).rejects.toThrow('Could not create a canvas context') + }) + + it('should reject when blob creation fails', async () => { + globalThis.Image = MockImageElement as unknown as typeof Image + + const sourceCanvas = createCanvasMock(createCanvasContextMock()) + const croppedCanvas = createCanvasMock(createCanvasContextMock(), null) + mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) + + await expect( + getCroppedImg('https://example.com/image.jpg', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.jpg'), + ).rejects.toThrow('Could not create a blob') + }) + }) + + describe('checkIsAnimatedImage', () => { + let originalFileReader: typeof FileReader + beforeEach(() => { + originalFileReader = globalThis.FileReader + }) + + afterEach(() => { + globalThis.FileReader = originalFileReader + }) + it('should return true for .gif files', async () => { + const gifFile = new File([new Uint8Array([0x47, 0x49, 0x46])], 'animation.gif', { type: 'image/gif' }) + const result = await checkIsAnimatedImage(gifFile) + expect(result).toBe(true) + }) + + it('should return false for non-gif, non-webp files', async () => { + const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'image.png', { type: 'image/png' }) + const result = await checkIsAnimatedImage(pngFile) + expect(result).toBe(false) + }) + + it('should return true for animated WebP files with ANIM chunk', async () => { + // Build a minimal WebP header with ANIM chunk + // RIFF....WEBP....ANIM + const bytes = new Uint8Array(20) + // RIFF signature + bytes[0] = 0x52 // R + bytes[1] = 0x49 // I + bytes[2] = 0x46 // F + bytes[3] = 0x46 // F + // WEBP signature + bytes[8] = 0x57 // W + bytes[9] = 0x45 // E + bytes[10] = 0x42 // B + bytes[11] = 0x50 // P + // ANIM chunk at offset 12 + bytes[12] = 0x41 // A + bytes[13] = 0x4E // N + bytes[14] = 0x49 // I + bytes[15] = 0x4D // M + + const webpFile = new File([bytes], 'animated.webp', { type: 'image/webp' }) + const result = await checkIsAnimatedImage(webpFile) + expect(result).toBe(true) + }) + + it('should return false for static WebP files without ANIM chunk', async () => { + const bytes = new Uint8Array(20) + // RIFF signature + bytes[0] = 0x52 + bytes[1] = 0x49 + bytes[2] = 0x46 + bytes[3] = 0x46 + // WEBP signature + bytes[8] = 0x57 + bytes[9] = 0x45 + bytes[10] = 0x42 + bytes[11] = 0x50 + // No ANIM chunk + + const webpFile = new File([bytes], 'static.webp', { type: 'image/webp' }) + const result = await checkIsAnimatedImage(webpFile) + expect(result).toBe(false) + }) + + it('should reject when FileReader encounters an error', async () => { + const file = new File([], 'test.png', { type: 'image/png' }) + + globalThis.FileReader = class { + onerror: ((error: ProgressEvent) => void) | null = null + onload: ((event: ProgressEvent) => void) | null = null + + readAsArrayBuffer(_blob: Blob) { + const errorEvent = new ProgressEvent('error') as ProgressEvent + setTimeout(() => { + this.onerror?.(errorEvent) + }, 0) + } + } as unknown as typeof FileReader + + await expect(checkIsAnimatedImage(file)).rejects.toBeInstanceOf(ProgressEvent) + }) + }) +}) diff --git a/web/app/components/base/app-unavailable.spec.tsx b/web/app/components/base/app-unavailable.spec.tsx new file mode 100644 index 0000000000..27fb359781 --- /dev/null +++ b/web/app/components/base/app-unavailable.spec.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react' +import AppUnavailable from './app-unavailable' + +describe('AppUnavailable', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/404/)).toBeInTheDocument() + }) + + it('should render the error code in a heading', () => { + render() + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toHaveTextContent(/404/) + }) + + it('should render the default unavailable message', () => { + render() + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display custom error code', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500') + }) + + it('should accept string error code', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403') + }) + + it('should apply custom className', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex', 'h-screen', 'w-screen', 'items-center', 'justify-center') + }) + + it('should display unknownReason when provided', () => { + render() + expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument() + }) + + it('should display unknown error translation when isUnknownReason is true', () => { + render() + expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument() + }) + + it('should prioritize unknownReason over isUnknownReason', () => { + render() + expect(screen.getByText(/My custom reason/i)).toBeInTheDocument() + }) + + it('should show appUnavailable translation when isUnknownReason is false', () => { + render() + expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with code 0', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0') + }) + + it('should render with an empty unknownReason and fall back to translation', () => { + render() + expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/audio-gallery/index.spec.tsx b/web/app/components/base/audio-gallery/index.spec.tsx new file mode 100644 index 0000000000..9039d4995c --- /dev/null +++ b/web/app/components/base/audio-gallery/index.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +// AudioGallery.spec.tsx +import { describe, expect, it, vi } from 'vitest' + +import AudioGallery from './index' + +// Mock AudioPlayer so we only assert prop forwarding +const audioPlayerMock = vi.fn() + +vi.mock('./AudioPlayer', () => ({ + default: (props: { srcs: string[] }) => { + audioPlayerMock(props) + return
+ }, +})) + +describe('AudioGallery', () => { + afterEach(() => { + audioPlayerMock.mockClear() + vi.resetModules() + }) + + it('returns null when srcs array is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + expect(screen.queryByTestId('audio-player')).toBeNull() + }) + + it('returns null when all srcs are falsy', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + expect(screen.queryByTestId('audio-player')).toBeNull() + }) + + it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => { + render() + expect(screen.getByTestId('audio-player')).toBeInTheDocument() + expect(audioPlayerMock).toHaveBeenCalledTimes(1) + expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['a.mp3', 'b.mp3'] }) + }) + + it('wraps AudioPlayer inside container with expected class', () => { + const { container } = render() + const root = container.firstChild as HTMLElement + expect(root).toBeTruthy() + expect(root.className).toContain('my-3') + }) +}) diff --git a/web/app/components/base/auto-height-textarea/index.spec.tsx b/web/app/components/base/auto-height-textarea/index.spec.tsx new file mode 100644 index 0000000000..f6ac0670df --- /dev/null +++ b/web/app/components/base/auto-height-textarea/index.spec.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { sleep } from '@/utils' +import AutoHeightTextarea from './index' + +vi.mock('@/utils', async () => { + const actual = await vi.importActual('@/utils') + return { + ...actual, + sleep: vi.fn(), + } +}) + +describe('AutoHeightTextarea', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + const textarea = container.querySelector('textarea') + expect(textarea).toBeInTheDocument() + }) + + it('should render with placeholder when value is empty', () => { + render() + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument() + }) + + it('should render with value', () => { + render() + const textarea = screen.getByDisplayValue('Hello World') + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className to textarea', () => { + const { container } = render() + const textarea = container.querySelector('textarea') + expect(textarea).toHaveClass('custom-class') + }) + + it('should apply custom wrapperClassName to wrapper div', () => { + const { container } = render() + const wrapper = container.querySelector('div.relative') + expect(wrapper).toHaveClass('wrapper-class') + }) + + it('should apply minHeight and maxHeight styles to hidden div', () => { + const { container } = render() + const hiddenDiv = container.querySelector('div.invisible') + expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' }) + }) + + it('should use default minHeight and maxHeight when not provided', () => { + const { container } = render() + const hiddenDiv = container.querySelector('div.invisible') + expect(hiddenDiv).toHaveStyle({ minHeight: '36px', maxHeight: '96px' }) + }) + + it('should set autoFocus on textarea', () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + render() + expect(focusSpy).toHaveBeenCalled() + focusSpy.mockRestore() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when textarea value changes', () => { + const handleChange = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.change(textarea, { target: { value: 'new value' } }) + + expect(handleChange).toHaveBeenCalledTimes(1) + }) + + it('should call onKeyDown when key is pressed', () => { + const handleKeyDown = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.keyDown(textarea, { key: 'Enter' }) + + expect(handleKeyDown).toHaveBeenCalledTimes(1) + }) + + it('should call onKeyUp when key is released', () => { + const handleKeyUp = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.keyUp(textarea, { key: 'Enter' }) + + expect(handleKeyUp).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string value', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should handle whitespace-only value', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(' ') + }) + + it('should handle very long text (>10000 chars)', () => { + const longText = 'a'.repeat(10001) + render() + const textarea = screen.getByDisplayValue(longText) + expect(textarea).toBeInTheDocument() + }) + + it('should handle newlines in value', () => { + const textWithNewlines = 'line1\nline2\nline3' + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(textWithNewlines) + }) + + it('should handle special characters in value', () => { + const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?' + render() + const textarea = screen.getByDisplayValue(specialChars) + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Ref forwarding', () => { + it('should accept ref and allow focusing', () => { + const ref = { current: null as HTMLTextAreaElement | null } + render(} value="" onChange={vi.fn()} />) + + expect(ref.current).not.toBeNull() + expect(ref.current?.tagName).toBe('TEXTAREA') + }) + }) + + describe('controlFocus prop', () => { + it('should call focus when controlFocus changes', () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + const { rerender } = render() + + expect(focusSpy).toHaveBeenCalledTimes(1) + + rerender() + + expect(focusSpy).toHaveBeenCalledTimes(2) + focusSpy.mockRestore() + }) + + it('should retry focus recursively when ref is not ready during autoFocus', async () => { + const delayedRef = {} as React.RefObject + let assignedNode: HTMLTextAreaElement | null = null + let exposedNode: HTMLTextAreaElement | null = null + + Object.defineProperty(delayedRef, 'current', { + get: () => exposedNode, + set: (value: HTMLTextAreaElement | null) => { + assignedNode = value + }, + }) + + const sleepMock = vi.mocked(sleep) + let sleepCalls = 0 + sleepMock.mockImplementation(async () => { + sleepCalls += 1 + if (sleepCalls === 2) + exposedNode = assignedNode + }) + + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + const setSelectionRangeSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'setSelectionRange') + + render() + + await waitFor(() => { + expect(sleepMock).toHaveBeenCalledTimes(2) + expect(focusSpy).toHaveBeenCalled() + expect(setSelectionRangeSpy).toHaveBeenCalledTimes(1) + }) + + focusSpy.mockRestore() + setSelectionRangeSpy.mockRestore() + }) + }) + + describe('displayName', () => { + it('should have displayName set', () => { + expect(AutoHeightTextarea.displayName).toBe('AutoHeightTextarea') + }) + }) +}) diff --git a/web/app/components/base/badge.spec.tsx b/web/app/components/base/badge.spec.tsx new file mode 100644 index 0000000000..5ca5cfe789 --- /dev/null +++ b/web/app/components/base/badge.spec.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react' +import Badge from './badge' + +describe('Badge', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/beta/i)).toBeInTheDocument() + }) + + it('should render with children instead of text', () => { + render(child content) + expect(screen.getByText(/child content/i)).toBeInTheDocument() + }) + + it('should render with no text or children', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent('') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('relative', 'inline-flex', 'h-5', 'items-center') + }) + + it('should apply uppercase class by default', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('system-2xs-medium-uppercase') + }) + + it('should apply non-uppercase class when uppercase is false', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('system-xs-medium') + expect(badge).not.toHaveClass('system-2xs-medium-uppercase') + }) + + it('should render red corner mark when hasRedCornerMark is true', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).toBeInTheDocument() + }) + + it('should not render red corner mark by default', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).not.toBeInTheDocument() + }) + + it('should prioritize children over text', () => { + render(child wins) + expect(screen.getByText(/child wins/i)).toBeInTheDocument() + expect(screen.queryByText(/text content/i)).not.toBeInTheDocument() + }) + + it('should render ReactNode as text prop', () => { + render(bold badge} />) + expect(screen.getByText(/bold badge/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty string text', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent('') + }) + + it('should render with hasRedCornerMark false explicitly', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/block-input/index.spec.tsx b/web/app/components/base/block-input/index.spec.tsx new file mode 100644 index 0000000000..8d8729287d --- /dev/null +++ b/web/app/components/base/block-input/index.spec.tsx @@ -0,0 +1,226 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import Toast from '@/app/components/base/toast' +import BlockInput, { getInputKeys } from './index' + +vi.mock('@/utils/var', () => ({ + checkKeys: vi.fn((_keys: string[]) => ({ + isValid: true, + errorMessageKey: '', + errorKey: '', + })), +})) + +describe('BlockInput', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Toast, 'notify') + cleanup() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + const wrapper = screen.getByTestId('block-input') + expect(wrapper).toBeInTheDocument() + }) + + it('should render with initial value', () => { + const { container } = render() + expect(container.textContent).toContain('Hello World') + }) + + it('should render variable highlights', () => { + render() + const nameElement = screen.getByText('name') + expect(nameElement).toBeInTheDocument() + expect(nameElement.parentElement).toHaveClass('text-primary-600') + }) + + it('should render multiple variable highlights', () => { + render() + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + }) + + it('should display character count in footer when not readonly', () => { + render() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should hide footer in readonly mode', () => { + render() + expect(screen.queryByText('5')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + render() + const innerContent = screen.getByTestId('block-input-content') + expect(innerContent).toHaveClass('custom-class') + }) + + it('should apply readonly prop with max height', () => { + render() + const contentDiv = screen.getByTestId('block-input').firstChild as Element + expect(contentDiv).toHaveClass('max-h-[180px]') + }) + + it('should have default empty value', () => { + render() + const contentDiv = screen.getByTestId('block-input') + expect(contentDiv).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should enter edit mode when clicked', async () => { + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + it('should update value when typing in edit mode', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var') + ; (checkKeys as ReturnType).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' }) + + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: 'Hello World' } }) + + expect(textarea).toHaveValue('Hello World') + }) + + it('should call onConfirm on value change with valid keys', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var') + ; (checkKeys as ReturnType).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' }) + + render() + + const contentArea = screen.getByText('initial') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: '{{name}}' } }) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name']) + }) + }) + + it('should show error toast on value change with invalid keys', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var'); + (checkKeys as ReturnType).mockReturnValue({ + isValid: false, + errorMessageKey: 'invalidKey', + errorKey: 'test_key', + }) + + render() + + const contentArea = screen.getByText('initial') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: '{{invalid}}' } }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalled() + }) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should not enter edit mode when readonly is true', () => { + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string value', () => { + const { container } = render() + expect(container.textContent).toBe('0') + const span = screen.getByTestId('block-input').querySelector('span') + expect(span).toBeInTheDocument() + expect(span).toBeEmptyDOMElement() + }) + + it('should handle value without variables', () => { + render() + expect(screen.getByText('plain text')).toBeInTheDocument() + }) + + it('should handle newlines in value', () => { + render() + expect(screen.getByText(/line1/)).toBeInTheDocument() + }) + + it('should handle multiple same variables', () => { + render() + const highlights = screen.getAllByText('name') + expect(highlights).toHaveLength(2) + }) + + it('should handle value with only variables', () => { + render() + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + expect(screen.getByText('baz')).toBeInTheDocument() + }) + + it('should handle text adjacent to variables', () => { + render() + expect(screen.getByText(/prefix/)).toBeInTheDocument() + expect(screen.getByText(/suffix/)).toBeInTheDocument() + }) + }) +}) + +describe('getInputKeys', () => { + it('should extract keys from {{}} syntax', () => { + const keys = getInputKeys('Hello {{name}}') + expect(keys).toEqual(['name']) + }) + + it('should extract multiple keys', () => { + const keys = getInputKeys('{{foo}} and {{bar}}') + expect(keys).toEqual(['foo', 'bar']) + }) + + it('should remove duplicate keys', () => { + const keys = getInputKeys('{{name}} and {{name}}') + expect(keys).toEqual(['name']) + }) + + it('should return empty array for no variables', () => { + const keys = getInputKeys('plain text') + expect(keys).toEqual([]) + }) + + it('should return empty array for empty string', () => { + const keys = getInputKeys('') + expect(keys).toEqual([]) + }) + + it('should handle keys with underscores and numbers', () => { + const keys = getInputKeys('{{user_1}} and {{user_2}}') + expect(keys).toEqual(['user_1', 'user_2']) + }) +}) diff --git a/web/app/components/base/block-input/index.tsx b/web/app/components/base/block-input/index.tsx index d9057eb737..05bb95e10b 100644 --- a/web/app/components/base/block-input/index.tsx +++ b/web/app/components/base/block-input/index.tsx @@ -63,7 +63,7 @@ const BlockInput: FC = ({ }, [isEditing]) const style = cn({ - 'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true, + 'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true, 'block-input--editing': isEditing, }) @@ -111,7 +111,7 @@ const BlockInput: FC = ({ // Prevent rerendering caused cursor to jump to the start of the contentEditable element const TextAreaContentView = () => { return ( -
+
{renderSafeContent(currentValue || '')}
) @@ -121,7 +121,7 @@ const BlockInput: FC = ({ const editAreaClassName = 'focus:outline-none bg-transparent text-sm' const textAreaContent = ( -
!readonly && setIsEditing(true)}> +
!readonly && setIsEditing(true)}> {isEditing ? (
@@ -134,10 +134,10 @@ const BlockInput: FC = ({ onBlur={() => { blur() setIsEditing(false) - // click confirm also make blur. Then outer value is change. So below code has problem. - // setTimeout(() => { - // handleCancel() - // }, 1000) + // click confirm also make blur. Then outer value is change. So below code has problem. + // setTimeout(() => { + // handleCancel() + // }, 1000) }} />
@@ -147,7 +147,7 @@ const BlockInput: FC = ({ ) return ( -
+
{textAreaContent} {/* footer */} {!readonly && ( diff --git a/web/app/components/base/button/add-button.spec.tsx b/web/app/components/base/button/add-button.spec.tsx new file mode 100644 index 0000000000..ad27753211 --- /dev/null +++ b/web/app/components/base/button/add-button.spec.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import AddButton from './add-button' + +describe('AddButton', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render an add icon', () => { + render() + const iconSpan = screen.getByTestId('add-button').querySelector('span') + expect(iconSpan).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + expect(container.firstChild).toHaveClass('rounded-md') + expect(container.firstChild).toHaveClass('select-none') + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild!) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick multiple times on repeated clicks', () => { + const onClick = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild!) + fireEvent.click(container.firstChild!) + fireEvent.click(container.firstChild!) + expect(onClick).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/base/button/add-button.tsx b/web/app/components/base/button/add-button.tsx index 332b52daca..92f9ae6800 100644 --- a/web/app/components/base/button/add-button.tsx +++ b/web/app/components/base/button/add-button.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { cn } from '@/utils/classnames' @@ -14,8 +13,8 @@ const AddButton: FC = ({ onClick, }) => { return ( -
- +
+
) } diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx new file mode 100644 index 0000000000..116aaaa7b0 --- /dev/null +++ b/web/app/components/base/button/sync-button.spec.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import SyncButton from './sync-button' + +describe('SyncButton', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render a refresh icon', () => { + render() + const iconSpan = screen.getByTestId('sync-button').querySelector('span') + expect(iconSpan).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + render() + const clickableDiv = screen.getByTestId('sync-button') + expect(clickableDiv).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + render() + const clickableDiv = screen.getByTestId('sync-button') + expect(clickableDiv).toHaveClass('rounded-md') + expect(clickableDiv).toHaveClass('select-none') + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + render() + const clickableDiv = screen.getByTestId('sync-button') + fireEvent.click(clickableDiv) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick multiple times on repeated clicks', () => { + const onClick = vi.fn() + render() + const clickableDiv = screen.getByTestId('sync-button') + fireEvent.click(clickableDiv) + fireEvent.click(clickableDiv) + fireEvent.click(clickableDiv) + expect(onClick).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/base/button/sync-button.tsx b/web/app/components/base/button/sync-button.tsx index 12c34026cb..06c155fb1d 100644 --- a/web/app/components/base/button/sync-button.tsx +++ b/web/app/components/base/button/sync-button.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { RiRefreshLine } from '@remixicon/react' import * as React from 'react' import TooltipPlus from '@/app/components/base/tooltip' import { cn } from '@/utils/classnames' @@ -18,8 +17,8 @@ const SyncButton: FC = ({ }) => { return ( -
- +
+
) diff --git a/web/app/components/base/carousel/index.spec.tsx b/web/app/components/base/carousel/index.spec.tsx new file mode 100644 index 0000000000..6bce414ee7 --- /dev/null +++ b/web/app/components/base/carousel/index.spec.tsx @@ -0,0 +1,231 @@ +import type { Mock } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import useEmblaCarousel from 'embla-carousel-react' +import { Carousel, useCarousel } from './index' + +vi.mock('embla-carousel-react', () => ({ + default: vi.fn(), +})) + +type EmblaEventName = 'reInit' | 'select' +type EmblaListener = (api: MockEmblaApi | undefined) => void + +type MockEmblaApi = { + scrollPrev: Mock + scrollNext: Mock + scrollTo: Mock + selectedScrollSnap: Mock + canScrollPrev: Mock + canScrollNext: Mock + slideNodes: Mock + on: Mock + off: Mock +} + +let mockCanScrollPrev = false +let mockCanScrollNext = false +let mockSelectedIndex = 0 +let mockSlideCount = 3 +let listeners: Record +let mockApi: MockEmblaApi +const mockCarouselRef = vi.fn() + +const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel) + +const createMockEmblaApi = (): MockEmblaApi => ({ + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + scrollTo: vi.fn(), + selectedScrollSnap: vi.fn(() => mockSelectedIndex), + canScrollPrev: vi.fn(() => mockCanScrollPrev), + canScrollNext: vi.fn(() => mockCanScrollNext), + slideNodes: vi.fn(() => + Array.from({ length: mockSlideCount }, () => document.createElement('div')), + ), + on: vi.fn((event: EmblaEventName, callback: EmblaListener) => { + listeners[event].push(callback) + }), + off: vi.fn((event: EmblaEventName, callback: EmblaListener) => { + listeners[event] = listeners[event].filter(listener => listener !== callback) + }), +}) + +const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => { + listeners[event].forEach((callback) => { + callback(api) + }) +} + +const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => { + return render( + + + Slide 1 + Slide 2 + Slide 3 + + Prev + Next + Dot + , + ) +} + +const mockPlugin = () => ({ + name: 'mock', + options: {}, + init: vi.fn(), + destroy: vi.fn(), +}) + +describe('Carousel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanScrollPrev = false + mockCanScrollNext = false + mockSelectedIndex = 0 + mockSlideCount = 3 + listeners = { reInit: [], select: [] } + mockApi = createMockEmblaApi() + + mockedUseEmblaCarousel.mockReturnValue( + [mockCarouselRef, mockApi] as unknown as ReturnType, + ) + }) + + // Rendering and basic semantic structure. + describe('Rendering', () => { + it('should render region and slides when used with content and items', () => { + renderCarouselWithControls() + + expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel') + expect(screen.getByTestId('carousel-content')).toHaveClass('flex') + screen.getAllByRole('group').forEach((slide) => { + expect(slide).toHaveAttribute('aria-roledescription', 'slide') + }) + }) + }) + + // Props should be translated into Embla options and visible layout. + describe('Props', () => { + it('should configure embla with horizontal axis when orientation is omitted', () => { + const plugin = mockPlugin() + render( + + + , + ) + + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( + { loop: true, axis: 'x' }, + [plugin], + ) + }) + + it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => { + renderCarouselWithControls('vertical') + + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( + { axis: 'y' }, + undefined, + ) + expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col') + }) + }) + + // Users can move slides through previous and next controls. + describe('User interactions', () => { + it('should call scroll handlers when previous and next buttons are clicked', () => { + mockCanScrollPrev = true + mockCanScrollNext = true + + renderCarouselWithControls() + + fireEvent.click(screen.getByRole('button', { name: 'Prev' })) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1) + expect(mockApi.scrollNext).toHaveBeenCalledTimes(1) + }) + + it('should call scrollTo with clicked index when a dot is clicked', () => { + renderCarouselWithControls() + const dots = screen.getAllByRole('button', { name: 'Dot' }) + + fireEvent.click(dots[2]) + + expect(mockApi.scrollTo).toHaveBeenCalledWith(2) + }) + }) + + // Embla events should keep control states and selected index in sync. + describe('State synchronization', () => { + it('should update disabled states and active dot when select event is emitted', () => { + renderCarouselWithControls() + + mockCanScrollPrev = true + mockCanScrollNext = true + mockSelectedIndex = 2 + + act(() => { + emitEmblaEvent('select') + }) + + const dots = screen.getAllByRole('button', { name: 'Dot' }) + expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled() + expect(dots[2]).toHaveAttribute('data-state', 'active') + }) + + it('should subscribe to embla events and unsubscribe from select on unmount', () => { + const { unmount } = renderCarouselWithControls() + + const selectCallback = mockApi.on.mock.calls.find( + call => call[0] === 'select', + )?.[1] as EmblaListener + + expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function)) + expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function)) + + unmount() + + expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback) + }) + }) + + // Edge-case behavior for missing providers or missing embla api values. + describe('Edge cases', () => { + it('should throw when useCarousel is used outside Carousel provider', () => { + const InvalidConsumer = () => { + useCarousel() + return null + } + + expect(() => render()).toThrowError( + 'useCarousel must be used within a ', + ) + }) + + it('should render with disabled controls and no dots when embla api is undefined', () => { + mockedUseEmblaCarousel.mockReturnValue( + [mockCarouselRef, undefined] as unknown as ReturnType, + ) + + renderCarouselWithControls() + + expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument() + }) + + it('should ignore select callback when embla emits an undefined api', () => { + renderCarouselWithControls() + + expect(() => { + act(() => { + emitEmblaEvent('select', undefined) + }) + }).not.toThrow() + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/agent-content.spec.tsx b/web/app/components/base/chat/chat/answer/agent-content.spec.tsx new file mode 100644 index 0000000000..ef4143fa6f --- /dev/null +++ b/web/app/components/base/chat/chat/answer/agent-content.spec.tsx @@ -0,0 +1,114 @@ +import type { ChatItem } from '../../types' +import type { IThoughtProps } from '@/app/components/base/chat/chat/thought' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { MarkdownProps } from '@/app/components/base/markdown' +import { render, screen } from '@testing-library/react' +import AgentContent from './agent-content' + +// Mock Markdown component used only in tests +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: (props: MarkdownProps & { 'data-testid'?: string }) => ( +
+ {String(props.content)} +
+ ), +})) + +// Mock Thought +vi.mock('@/app/components/base/chat/chat/thought', () => ({ + default: ({ thought, isFinished }: IThoughtProps) => ( +
+ {thought.thought} +
+ ), +})) + +// Mock FileList and Utils +vi.mock('@/app/components/base/file-uploader', () => ({ + FileList: ({ files }: { files: FileEntity[] }) => ( +
+ {files.map(f => f.name).join(', ')} +
+ ), +})) + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getProcessedFilesFromResponse: (files: FileEntity[]) => files.map(f => ({ ...f, name: `processed-${f.id}` })), +})) + +describe('AgentContent', () => { + const mockItem: ChatItem = { + id: '1', + content: '', + isAnswer: true, + } + + it('renders logAnnotation if present', () => { + const itemWithAnnotation = { + ...mockItem, + annotation: { + logAnnotation: { content: 'Log Annotation Content' }, + }, + } + render() + expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content') + }) + + it('renders content prop if provided and no annotation', () => { + render() + expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content') + }) + + it('renders agent_thoughts if content is absent', () => { + const itemWithThoughts = { + ...mockItem, + agent_thoughts: [ + { thought: 'Thought 1', tool: 'tool1' }, + { thought: 'Thought 2' }, + ], + } + render() + const items = screen.getAllByTestId('agent-thought-item') + expect(items).toHaveLength(2) + const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown') + expect(thoughtMarkdowns[0]).toHaveTextContent('Thought 1') + expect(thoughtMarkdowns[1]).toHaveTextContent('Thought 2') + expect(screen.getByTestId('thought-component')).toHaveTextContent('Thought 1') + }) + + it('passes correct isFinished to Thought component', () => { + const itemWithThoughts = { + ...mockItem, + agent_thoughts: [ + { thought: 'T1', tool: 'tool1', observation: 'obs1' }, // finished by observation + { thought: 'T2', tool: 'tool2' }, // finished by responding=false + ], + } + const { rerender } = render() + const thoughts = screen.getAllByTestId('thought-component') + expect(thoughts[0]).toHaveAttribute('data-finished', 'true') + expect(thoughts[1]).toHaveAttribute('data-finished', 'false') + + rerender() + expect(screen.getAllByTestId('thought-component')[1]).toHaveAttribute('data-finished', 'true') + }) + + it('renders FileList if thought has message_files', () => { + const itemWithFiles = { + ...mockItem, + agent_thoughts: [ + { + thought: 'T1', + message_files: [{ id: 'file1' }, { id: 'file2' }], + }, + ], + } + render() + expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2') + }) + + it('renders nothing if no annotation, content, or thoughts', () => { + render() + expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/agent-content.tsx b/web/app/components/base/chat/chat/answer/agent-content.tsx index d8009f13d4..579c1836e9 100644 --- a/web/app/components/base/chat/chat/answer/agent-content.tsx +++ b/web/app/components/base/chat/chat/answer/agent-content.tsx @@ -23,15 +23,29 @@ const AgentContent: FC = ({ agent_thoughts, } = item - if (annotation?.logAnnotation) - return + if (annotation?.logAnnotation) { + return ( + + ) + } return ( -
- {content ? : agent_thoughts?.map((thought, index) => ( -
+
+ {content ? ( + + ) : agent_thoughts?.map((thought, index) => ( +
{thought.thought && ( - + )} {/* {item.tool} */} {/* perhaps not use tool */} diff --git a/web/app/components/base/chat/chat/answer/basic-content.spec.tsx b/web/app/components/base/chat/chat/answer/basic-content.spec.tsx new file mode 100644 index 0000000000..9a03ea9d40 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/basic-content.spec.tsx @@ -0,0 +1,91 @@ +import type { ChatItem } from '../../types' +import type { MarkdownProps } from '@/app/components/base/markdown' +import { render, screen } from '@testing-library/react' +import BasicContent from './basic-content' + +// Mock Markdown component used only in tests +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content, className }: MarkdownProps) => ( +
+ {String(content)} +
+ ), +})) + +describe('BasicContent', () => { + const mockItem = { + id: '1', + content: 'Hello World', + isAnswer: true, + } + + it('renders content correctly', () => { + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', 'Hello World') + }) + + it('renders logAnnotation content if present', () => { + const itemWithAnnotation = { + ...mockItem, + annotation: { + logAnnotation: { + content: 'Annotated Content', + }, + }, + } + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', 'Annotated Content') + }) + + it('wraps Windows UNC paths in backticks', () => { + const itemWithUNC = { + ...mockItem, + content: '\\\\server\\share\\file.txt', + } + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`') + }) + + it('does not wrap content in backticks if it already is', () => { + const itemWithBackticks = { + ...mockItem, + content: '`\\\\server\\share\\file.txt`', + } + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`') + }) + + it('does not wrap backslash strings that are not UNC paths', () => { + const itemWithBackslashes = { + ...mockItem, + content: '\\not-a-unc', + } + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', '\\not-a-unc') + }) + + it('applies error class when isError is true', () => { + const errorItem = { + ...mockItem, + isError: true, + } + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveClass('!text-[#F04438]') + }) + + it('renders non-string content without attempting to wrap (covers typeof !== "string" branch)', () => { + const itemWithNonStringContent = { + ...mockItem, + content: 12345, + } + render() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', '12345') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/basic-content.tsx b/web/app/components/base/chat/chat/answer/basic-content.tsx index cda2dd6ffb..15c1125b0f 100644 --- a/web/app/components/base/chat/chat/answer/basic-content.tsx +++ b/web/app/components/base/chat/chat/answer/basic-content.tsx @@ -15,8 +15,14 @@ const BasicContent: FC = ({ content, } = item - if (annotation?.logAnnotation) - return + if (annotation?.logAnnotation) { + return ( + + ) + } // Preserve Windows UNC paths and similar backslash-heavy strings by // wrapping them in inline code so Markdown renders backslashes verbatim. @@ -31,6 +37,7 @@ const BasicContent: FC = ({ item.isError && '!text-[#F04438]', )} content={displayContent} + data-testid="basic-content-markdown" /> ) } diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx new file mode 100644 index 0000000000..2c762f37b5 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx @@ -0,0 +1,111 @@ +import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import ContentItem from './content-item' + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +describe('ContentItem', () => { + const mockOnInputChange = vi.fn() + const mockFormInputFields: FormInputItem[] = [ + { + type: 'paragraph', + output_variable_name: 'user_bio', + default: { + type: 'constant', + value: '', + selector: [], + }, + } as FormInputItem, + ] + const mockInputs = { + user_bio: 'Initial bio', + } + + it('should render Markdown for literal content', () => { + render( + , + ) + + expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Hello world') + expect(screen.queryByTestId('content-item-textarea')).not.toBeInTheDocument() + }) + + it('should render Textarea for valid output variable content', () => { + render( + , + ) + + const textarea = screen.getByTestId('content-item-textarea') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveValue('Initial bio') + expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument() + }) + + it('should call onInputChange when textarea value changes', async () => { + const user = userEvent.setup() + render( + , + ) + + const textarea = screen.getByTestId('content-item-textarea') + await user.type(textarea, 'x') + + expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox') + }) + + it('should render nothing if field name is valid but not found in formInputFields', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it('should render nothing if input type is not supported', () => { + const { container } = render( + , + ) + + expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument() + expect(container.querySelector('.py-3')?.textContent).toBe('') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx index 3c9cd617d0..9649a92167 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx @@ -45,6 +45,7 @@ const ContentItem = ({ className="h-[104px] sm:text-xs" value={inputs[fieldName]} onChange={(e) => { onInputChange(fieldName, e.target.value) }} + data-testid="content-item-textarea" /> )}
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx new file mode 100644 index 0000000000..36f264a834 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import ContentWrapper from './content-wrapper' + +describe('ContentWrapper', () => { + const defaultProps = { + nodeTitle: 'Human Input Node', + children:
Child Content
, + } + + it('should render node title and children by default when not collapsible', () => { + render() + + expect(screen.getByText('Human Input Node')).toBeInTheDocument() + expect(screen.getByTestId('child-content')).toBeInTheDocument() + expect(screen.queryByTestId('expand-icon')).not.toBeInTheDocument() + }) + + it('should show/hide content when toggling expansion', async () => { + const user = userEvent.setup() + render() + + // Initially collapsed + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + const expandToggle = screen.getByTestId('expand-icon') + expect(expandToggle.querySelector('.i-ri-arrow-right-s-line')).toBeInTheDocument() + + // Expand + await user.click(expandToggle) + expect(screen.getByTestId('child-content')).toBeInTheDocument() + expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() + + // Collapse + await user.click(expandToggle) + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + }) + + it('should render children initially if expanded is true', () => { + render() + + expect(screen.getByTestId('child-content')).toBeInTheDocument() + const expandToggle = screen.getByTestId('expand-icon') + expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx index acd154e30a..85d8affb71 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx @@ -1,4 +1,3 @@ -import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' import { useCallback, useState } from 'react' import BlockIcon from '@/app/components/workflow/block-icon' import { BlockEnum } from '@/app/components/workflow/types' @@ -26,26 +25,33 @@ const ContentWrapper = ({ }, [isExpanded]) return ( -
+
{/* node icon */} {/* node name */}
{nodeTitle}
{showExpandIcon && ( -
+
{ isExpanded ? ( - +
) : ( - +
) }
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx new file mode 100644 index 0000000000..3f2e6e4beb --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ExecutedAction from './executed-action' + +describe('ExecutedAction', () => { + it('should render the triggered action information', () => { + const executedAction = { + id: 'btn_1', + title: 'Submit', + } + + render() + + expect(screen.getByTestId('executed-action')).toBeInTheDocument() + + // Trans component mock from i18n-mock.ts renders a span with data-i18n-key + const trans = screen.getByTestId('executed-action').querySelector('span') + expect(trans).toHaveAttribute('data-i18n-key', 'nodes.humanInput.userActions.triggered') + + // Check for the trigger icon class + expect(screen.getByTestId('executed-action').querySelector('.i-custom-vender-workflow-trigger-all')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx index ccdfcb624b..a063fee777 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx @@ -2,7 +2,6 @@ import type { ExecutedAction as ExecutedActionType } from './type' import { memo } from 'react' import { Trans } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' type ExecutedActionProps = { executedAction: ExecutedActionType @@ -12,14 +11,14 @@ const ExecutedAction = ({ executedAction, }: ExecutedActionProps) => { return ( -
+
-
- +
+
}} + components={{ strong: }} values={{ actionName: executedAction.id }} />
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx new file mode 100644 index 0000000000..fdf3a3244b --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ExpirationTime from './expiration-time' +import * as utils from './utils' + +// Mock utils to control time-based logic +vi.mock('./utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getRelativeTime: vi.fn(), + isRelativeTimeSameOrAfter: vi.fn(), + } +}) + +describe('ExpirationTime', () => { + it('should render "Future" state with relative time', () => { + vi.mocked(utils.getRelativeTime).mockReturnValue('in 2 hours') + vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(true) + + const { container } = render() + + expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-tertiary') + expect(screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}')).toBeInTheDocument() + expect(container.querySelector('.i-ri-time-line')).toBeInTheDocument() + }) + + it('should render "Expired" state when time is in the past', () => { + vi.mocked(utils.getRelativeTime).mockReturnValue('2 hours ago') + vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(false) + + const { container } = render() + + expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning') + expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument() + expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx index 786440dc6b..c3a2f2fdfa 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx @@ -1,5 +1,4 @@ 'use client' -import { RiAlertFill, RiTimeLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useLocale } from '@/context/i18n' import { cn } from '@/utils/classnames' @@ -19,8 +18,9 @@ const ExpirationTime = ({ return (
@@ -28,13 +28,13 @@ const ExpirationTime = ({ isSameOrAfter ? ( <> - +
{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })} ) : ( <> - +
{t('humanInput.expiredTip', { ns: 'share' })} ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx new file mode 100644 index 0000000000..e9d6fdee3c --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx @@ -0,0 +1,132 @@ +import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' +import type { HumanInputFormData } from '@/types/workflow' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import HumanInputForm from './human-input-form' + +vi.mock('./content-item', () => ({ + default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => ( +
+ {content} + +
+ ), +})) + +describe('HumanInputForm', () => { + const mockFormData: HumanInputFormData = { + form_id: 'form_1', + node_id: 'node_1', + node_title: 'Title', + display_in_ui: true, + expiration_time: 0, + form_token: 'token_123', + form_content: 'Part 1 {{#$output.field1#}} Part 2', + inputs: [ + { + type: 'paragraph', + output_variable_name: 'field1', + default: { type: 'constant', value: 'initial', selector: [] }, + } as FormInputItem, + ], + actions: [ + { id: 'action_1', title: 'Submit', button_style: UserActionButtonType.Primary }, + { id: 'action_2', title: 'Cancel', button_style: UserActionButtonType.Default }, + { id: 'action_3', title: 'Accent', button_style: UserActionButtonType.Accent }, + { id: 'action_4', title: 'Ghost', button_style: UserActionButtonType.Ghost }, + ], + resolved_default_values: {}, + } + + it('should render content parts and action buttons', () => { + render() + + // splitByOutputVar should yield 3 parts: "Part 1 ", "{{#$output.field1#}}", " Part 2" + const contentItems = screen.getAllByTestId('mock-content-item') + expect(contentItems).toHaveLength(3) + expect(contentItems[0]).toHaveTextContent('Part 1') + expect(contentItems[1]).toHaveTextContent('{{#$output.field1#}}') + expect(contentItems[2]).toHaveTextContent('Part 2') + + const buttons = screen.getAllByTestId('action-button') + expect(buttons).toHaveLength(4) + expect(buttons[0]).toHaveTextContent('Submit') + expect(buttons[1]).toHaveTextContent('Cancel') + expect(buttons[2]).toHaveTextContent('Accent') + expect(buttons[3]).toHaveTextContent('Ghost') + }) + + it('should handle input changes and submit correctly', async () => { + const user = userEvent.setup() + const mockOnSubmit = vi.fn().mockResolvedValue(undefined) + render() + + // Update input via mock ContentItem + await user.click(screen.getAllByTestId('update-input')[0]) + + // Submit + const submitButton = screen.getByRole('button', { name: 'Submit' }) + await user.click(submitButton) + + expect(mockOnSubmit).toHaveBeenCalledWith('token_123', { + action: 'action_1', + inputs: { field1: 'new value' }, + }) + }) + + it('should disable buttons during submission', async () => { + const user = userEvent.setup() + let resolveSubmit: (value: void | PromiseLike) => void + const submitPromise = new Promise((resolve) => { + resolveSubmit = resolve + }) + const mockOnSubmit = vi.fn().mockReturnValue(submitPromise) + + render() + + const submitButton = screen.getByRole('button', { name: 'Submit' }) + const cancelButton = screen.getByRole('button', { name: 'Cancel' }) + + await user.click(submitButton) + + expect(submitButton).toBeDisabled() + expect(cancelButton).toBeDisabled() + + // Finish submission + await act(async () => { + resolveSubmit!(undefined) + }) + + expect(submitButton).not.toBeDisabled() + expect(cancelButton).not.toBeDisabled() + }) + + it('should handle missing resolved_default_values', () => { + const formDataWithoutDefaults = { ...mockFormData, resolved_default_values: undefined } + render() + expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3) + }) + + it('should handle unsupported input types in initializeInputs', () => { + const formDataWithUnsupported = { + ...mockFormData, + inputs: [ + { + type: 'text-input', + output_variable_name: 'field2', + default: { type: 'variable', value: '', selector: [] }, + } as FormInputItem, + { + type: 'number', + output_variable_name: 'field3', + default: { type: 'constant', value: '0', selector: [] }, + } as FormInputItem, + ], + resolved_default_values: { field2: 'default value' }, + } + render() + expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 0b5d54ab7e..2c22fabdb5 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -49,6 +49,7 @@ const HumanInputForm = ({ disabled={isSubmitting} variant={getButtonStyle(action.button_style) as ButtonProps['variant']} onClick={() => submit(formToken, action.id, inputs)} + data-testid="action-button" > {action.title} diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx new file mode 100644 index 0000000000..f56b081370 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SubmittedContent from './submitted-content' + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +describe('SubmittedContent', () => { + it('should render Markdown with the provided content', () => { + const content = '## Test Content' + render() + + expect(screen.getByTestId('submitted-content')).toBeInTheDocument() + expect(screen.getByTestId('mock-markdown')).toHaveTextContent(content) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx index 68d55f7d64..d56ca4676d 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx @@ -9,7 +9,9 @@ const SubmittedContent = ({ content, }: SubmittedContentProps) => { return ( - +
+ +
) } diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx new file mode 100644 index 0000000000..3ea4a25fcd --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx @@ -0,0 +1,31 @@ +import type { HumanInputFilledFormData } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SubmittedHumanInputContent } from './submitted' + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +describe('SubmittedHumanInputContent Integration', () => { + const mockFormData: HumanInputFilledFormData = { + rendered_content: 'Rendered **Markdown** content', + action_id: 'btn_1', + action_text: 'Submit Action', + node_id: 'node_1', + node_title: 'Node Title', + } + + it('should render both content and executed action', () => { + render() + + // Verify SubmittedContent rendering + expect(screen.getByTestId('submitted-content')).toBeInTheDocument() + expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content') + + // Verify ExecutedAction rendering + expect(screen.getByTestId('executed-action')).toBeInTheDocument() + // Trans component for triggered action. The mock usually renders the key. + expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx new file mode 100644 index 0000000000..44a92f0e0b --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx @@ -0,0 +1,83 @@ +import type { AppContextValue } from '@/context/app-context' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useSelector } from '@/context/app-context' +import Tips from './tips' + +// Mock AppContext's useSelector to control user profile data +vi.mock('@/context/app-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSelector: vi.fn(), + } +}) + +describe('Tips', () => { + const mockEmail = 'test@example.com' + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => { + return selector({ + userProfile: { + email: mockEmail, + }, + } as AppContextValue) + }) + }) + + it('should render email tip in normal mode', () => { + render( + , + ) + + expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument() + expect(screen.queryByText('common.humanInputEmailTipInDebugMode')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.humanInputWebappTip')).not.toBeInTheDocument() + }) + + it('should render email tip in debug mode', () => { + render( + , + ) + + expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument() + expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument() + }) + + it('should render debug mode tip', () => { + render( + , + ) + + expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument() + expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument() + }) + + it('should render nothing when all flags are false', () => { + const { container } = render( + , + ) + + expect(screen.queryByTestId('tips')).toBeEmptyDOMElement() + // Divider is outside of tips container, but within the fragment + expect(container.querySelector('.v-divider')).toBeDefined() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx index 54cfc8c5a5..9fac47a4a6 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx @@ -20,12 +20,12 @@ const Tips = ({ return ( <> -
+
{showEmailTip && !isEmailDebugMode && ( -
{t('common.humanInputEmailTip', { ns: 'workflow' })}
+
{t('common.humanInputEmailTip', { ns: 'workflow' })}
)} {showEmailTip && isEmailDebugMode && ( -
+
)} - {showDebugModeTip &&
{t('common.humanInputWebappTip', { ns: 'workflow' })}
} + {showDebugModeTip &&
{t('common.humanInputWebappTip', { ns: 'workflow' })}
}
) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx new file mode 100644 index 0000000000..192b4f08b4 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx @@ -0,0 +1,212 @@ +import type { InputVarType } from '@/app/components/workflow/types' +import type { AppContextValue } from '@/context/app-context' +import type { HumanInputFormData } from '@/types/workflow' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { useSelector } from '@/context/app-context' +import { UnsubmittedHumanInputContent } from './unsubmitted' + +// Mock AppContext's useSelector to control user profile data +vi.mock('@/context/app-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSelector: vi.fn(), + } +}) + +describe('UnsubmittedHumanInputContent Integration', () => { + const user = userEvent.setup() + + // Helper to create valid form data + const createMockFormData = (overrides = {}): HumanInputFormData => ({ + form_id: 'form_123', + node_id: 'node_456', + node_title: 'Input Form', + form_content: 'Fill this out: {{#$output.user_name#}}', + inputs: [ + { + type: 'paragraph' as InputVarType, + output_variable_name: 'user_name', + default: { + type: 'constant', + value: 'Default value', + selector: [], + }, + }, + ], + actions: [ + { id: 'btn_1', title: 'Submit', button_style: UserActionButtonType.Primary }, + ], + form_token: 'token_123', + resolved_default_values: {}, + expiration_time: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + display_in_ui: true, + ...overrides, + } as unknown as HumanInputFormData) + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => { + return selector({ + userProfile: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + avatar: '', + avatar_url: '', + is_password_set: false, + }, + } as AppContextValue) + }) + }) + + describe('Rendering', () => { + it('should render form, tips, and expiration time when all conditions met', () => { + render( + , + ) + + expect(screen.getByText('Submit')).toBeInTheDocument() + expect(screen.getByTestId('tips')).toBeInTheDocument() + expect(screen.getByTestId('expiration-time')).toBeInTheDocument() + expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument() + }) + + it('should hide ExpirationTime when expiration_time is not a number', () => { + const data = createMockFormData({ expiration_time: undefined }) + render() + + expect(screen.queryByTestId('expiration-time')).not.toBeInTheDocument() + }) + + it('should hide Tips when both tip flags are false', () => { + render( + , + ) + + expect(screen.queryByTestId('tips')).not.toBeInTheDocument() + }) + + it('should render different email tips based on debug mode', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument() + }) + + it('should render "Expired" state when expiration time is in the past', () => { + const data = createMockFormData({ expiration_time: Math.floor(Date.now() / 1000) - 3600 }) + render() + + expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should update input values and call onSubmit', async () => { + const handleSubmit = vi.fn().mockImplementation(() => Promise.resolve()) + const data = createMockFormData() + + render() + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New Value') + + const submitBtn = screen.getByRole('button', { name: 'Submit' }) + await user.click(submitBtn) + + expect(handleSubmit).toHaveBeenCalledWith('token_123', { + action: 'btn_1', + inputs: { user_name: 'New Value' }, + }) + }) + + it('should handle loading state during submission', async () => { + let resolveSubmit: (value: void | PromiseLike) => void + const handleSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveSubmit = resolve + })) + const data = createMockFormData() + + render() + + const submitBtn = screen.getByRole('button', { name: 'Submit' }) + await user.click(submitBtn) + + expect(submitBtn).toBeDisabled() + expect(handleSubmit).toHaveBeenCalled() + + await waitFor(() => { + resolveSubmit!() + }) + + await waitFor(() => expect(submitBtn).not.toBeDisabled()) + }) + }) + + describe('Edge Cases', () => { + it('should handle missing resolved_default_values', () => { + const data = createMockFormData({ resolved_default_values: undefined }) + render() + expect(screen.getByText('Submit')).toBeInTheDocument() + }) + + it('should return null in ContentItem if field is not found', () => { + const data = createMockFormData({ + form_content: '{{#$output.unknown_field#}}', + inputs: [], + }) + const { container } = render() + // The form will be empty (except for buttons) because unknown_field is not in inputs + expect(container.querySelector('textarea')).not.toBeInTheDocument() + }) + + it('should render text-input type in initializeInputs correctly', () => { + const data = createMockFormData({ + inputs: [ + { + type: 'text-input', + output_variable_name: 'var1', + label: 'Var 1', + required: true, + default: { type: 'fixed', value: 'fixed_val' }, + }, + ], + }) + render() + // initializeInputs is tested indirectly here. + // We can't easily assert the internal state of HumanInputForm, but we can verify it doesn't crash. + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx new file mode 100644 index 0000000000..5eceddd444 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx @@ -0,0 +1,58 @@ +import type { HumanInputFilledFormData } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import HumanInputFilledFormList from './human-input-filled-form-list' + +/** + * Type-safe factory. + * Forces test data to match real interface. + */ +const createFormData = ( + overrides: Partial = {}, +): HumanInputFilledFormData => ({ + node_id: 'node-1', + node_title: 'Node Title', + + // 👇 IMPORTANT + // DO NOT guess properties like `inputs` + // Only include fields that actually exist in your project type. + // Leave everything else empty via spread. + ...overrides, +} as HumanInputFilledFormData) + +describe('HumanInputFilledFormList', () => { + it('renders nothing when list is empty', () => { + render() + + expect(screen.queryByText('Node Title')).not.toBeInTheDocument() + }) + + it('renders one form item', () => { + const data = [createFormData()] + + render() + + expect(screen.getByText('Node Title')).toBeInTheDocument() + }) + + it('renders multiple form items', () => { + const data = [ + createFormData({ node_id: '1', node_title: 'First' }), + createFormData({ node_id: '2', node_title: 'Second' }), + ] + + render() + + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('renders wrapper container', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('flex') + expect(container.firstChild).toHaveClass('flex-col') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx new file mode 100644 index 0000000000..4bfd3a7d97 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx @@ -0,0 +1,131 @@ +import type { HumanInputFormData } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types' +import HumanInputFormList from './human-input-form-list' + +// Mock child components +vi.mock('./human-input-content/content-wrapper', () => ({ + default: ({ children, nodeTitle }: { children: React.ReactNode, nodeTitle: string }) => ( +
+ {children} +
+ ), +})) + +vi.mock('./human-input-content/unsubmitted', () => ({ + UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => ( +
+ {showEmailTip ? 'true' : 'false'} + {isEmailDebugMode ? 'true' : 'false'} + {showDebugModeTip ? 'true' : 'false'} +
+ ), +})) + +describe('HumanInputFormList', () => { + const mockFormData = [ + { + form_id: 'form1', + node_id: 'node1', + node_title: 'Title 1', + display_in_ui: true, + }, + { + form_id: 'form2', + node_id: 'node2', + node_title: 'Title 2', + display_in_ui: false, + }, + ] + + const mockGetNodeData = vi.fn() + + it('should render empty list when no form data is provided', () => { + render() + expect(screen.getByTestId('human-input-form-list')).toBeEmptyDOMElement() + }) + + it('should render only items with display_in_ui set to true', () => { + mockGetNodeData.mockReturnValue({ + data: { + delivery_methods: [], + }, + }) + render( + , + ) + const items = screen.getAllByTestId('human-input-form-item') + expect(items).toHaveLength(1) + expect(screen.getByTestId('content-wrapper')).toHaveAttribute('data-nodetitle', 'Title 1') + }) + + describe('Delivery Methods Config', () => { + it('should set default tips when node data is not found', () => { + mockGetNodeData.mockReturnValue(undefined) + render( + , + ) + expect(screen.getByTestId('email-tip')).toHaveTextContent('false') + expect(screen.getByTestId('email-debug')).toHaveTextContent('false') + expect(screen.getByTestId('debug-tip')).toHaveTextContent('false') + }) + + it('should set default tips when delivery_methods is empty', () => { + mockGetNodeData.mockReturnValue({ data: { delivery_methods: [] } }) + render( + , + ) + expect(screen.getByTestId('email-tip')).toHaveTextContent('false') + expect(screen.getByTestId('email-debug')).toHaveTextContent('false') + expect(screen.getByTestId('debug-tip')).toHaveTextContent('false') + }) + + it('should show tips correctly based on delivery methods', () => { + mockGetNodeData.mockReturnValue({ + data: { + delivery_methods: [ + { type: DeliveryMethodType.WebApp, enabled: true }, + { type: DeliveryMethodType.Email, enabled: true, config: { debug_mode: true } }, + ], + }, + }) + render( + , + ) + expect(screen.getByTestId('email-tip')).toHaveTextContent('true') + expect(screen.getByTestId('email-debug')).toHaveTextContent('true') + expect(screen.getByTestId('debug-tip')).toHaveTextContent('false') // WebApp is enabled + }) + + it('should show debug mode tip if WebApp is disabled', () => { + mockGetNodeData.mockReturnValue({ + data: { + delivery_methods: [ + { type: DeliveryMethodType.WebApp, enabled: false }, + { type: DeliveryMethodType.Email, enabled: false }, + ], + }, + }) + render( + , + ) + expect(screen.getByTestId('email-tip')).toHaveTextContent('false') + expect(screen.getByTestId('debug-tip')).toHaveTextContent('true') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx index 1403bcb600..47dcd72094 100644 --- a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx @@ -45,22 +45,28 @@ const HumanInputFormList = ({ const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui) return ( -
+
{ filteredHumanInputFormDataList.map(formData => ( - - - + + + +
)) }
diff --git a/web/app/components/base/chat/chat/answer/more.spec.tsx b/web/app/components/base/chat/chat/answer/more.spec.tsx new file mode 100644 index 0000000000..551c15e659 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/more.spec.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react' +import More from './more' + +describe('More', () => { + const mockMoreData = { + latency: 0.5, + tokens: 100, + tokens_per_second: 200, + time: '2023-10-27 10:00:00', + } + + it('should render all details when all data is provided', () => { + render() + + expect(screen.getByTestId('more-container')).toBeInTheDocument() + + // Check latency + expect(screen.getByTestId('more-latency')).toBeInTheDocument() + expect(screen.getByText(/timeConsuming/i)).toBeInTheDocument() + expect(screen.getByText(/0.5/)).toBeInTheDocument() + expect(screen.getByText(/second/i)).toBeInTheDocument() + + // Check tokens + expect(screen.getByTestId('more-tokens')).toBeInTheDocument() + expect(screen.getByText(/tokenCost/i)).toBeInTheDocument() + expect(screen.getByText(/100/)).toBeInTheDocument() + + // Check tokens per second + expect(screen.getByTestId('more-tps')).toBeInTheDocument() + expect(screen.getByText(/200 tokens\/s/i)).toBeInTheDocument() + + // Check time + expect(screen.getByTestId('more-time')).toBeInTheDocument() + expect(screen.getByText('2023-10-27 10:00:00')).toBeInTheDocument() + }) + + it('should not render tokens per second when it is missing', () => { + const dataWithoutTPS = { ...mockMoreData, tokens_per_second: 0 } + render() + + expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument() + }) + + it('should render nothing inside container if more prop is missing', () => { + render() + const containerDiv = screen.getByTestId('more-container') + expect(containerDiv).toBeInTheDocument() + expect(containerDiv.children.length).toBe(0) + }) + + it('should apply group-hover opacity classes', () => { + render() + const container = screen.getByTestId('more-container') + expect(container).toHaveClass('opacity-0') + expect(container).toHaveClass('group-hover:opacity-100') + }) + + it('should correctly format large token counts', () => { + const dataWithLargeTokens = { ...mockMoreData, tokens: 1234567 } + render() + + // formatNumber(1234567) should return '1,234,567' + expect(screen.getByText(/1,234,567/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/more.tsx b/web/app/components/base/chat/chat/answer/more.tsx index c091418cef..700c548ee4 100644 --- a/web/app/components/base/chat/chat/answer/more.tsx +++ b/web/app/components/base/chat/chat/answer/more.tsx @@ -13,19 +13,24 @@ const More: FC = ({ const { t } = useTranslation() return ( -
+
{ more && ( <>
{`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
@@ -33,6 +38,7 @@ const More: FC = ({
{`${more.tokens_per_second} tokens/s`}
@@ -41,6 +47,7 @@ const More: FC = ({
{more.time}
diff --git a/web/app/components/base/chat/chat/answer/operation.spec.tsx b/web/app/components/base/chat/chat/answer/operation.spec.tsx new file mode 100644 index 0000000000..eb52dffe8f --- /dev/null +++ b/web/app/components/base/chat/chat/answer/operation.spec.tsx @@ -0,0 +1,726 @@ +import type { ChatConfig, ChatItem } from '../../types' +import type { ChatContextValue } from '../context' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import copy from 'copy-to-clipboard' +import * as React from 'react' +import { vi } from 'vitest' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import Operation from './operation' + +const { + mockSetShowAnnotationFullModal, + mockProviderContext, + mockT, + mockAddAnnotation, +} = vi.hoisted(() => { + return { + mockAddAnnotation: vi.fn(), + mockSetShowAnnotationFullModal: vi.fn(), + mockT: vi.fn((key: string): string => key), + mockProviderContext: { + plan: { + usage: { annotatedResponse: 0 }, + total: { annotatedResponse: 100 }, + }, + enableBilling: false, + }, + } +}) + +vi.mock('copy-to-clipboard', () => ({ default: vi.fn() })) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAnnotationFullModal: mockSetShowAnnotationFullModal, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderContext, +})) + +vi.mock('@/service/annotation', () => ({ + addAnnotation: mockAddAnnotation, +})) + +vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: vi.fn(() => ({ + getAudioPlayer: vi.fn(() => ({ + playAudio: vi.fn(), + pauseAudio: vi.fn(), + })), + })), + }, +})) + +vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({ + default: ({ isShow, onHide, onEdited, onAdded, onRemove }: { + isShow: boolean + onHide: () => void + onEdited: (q: string, a: string) => void + onAdded: (id: string, name: string, q: string, a: string) => void + onRemove: () => void + }) => + isShow + ? ( +
+ + + + +
+ ) + : null, +})) + +vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', () => ({ + default: function AnnotationCtrlMock({ onAdded, onEdit, cached }: { + onAdded: (id: string, authorName: string) => void + onEdit: () => void + cached: boolean + }) { + const { setShowAnnotationFullModal } = useModalContext() + const { plan, enableBilling } = useProviderContext() + const handleAdd = () => { + if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) { + setShowAnnotationFullModal() + return + } + onAdded('ann-new', 'Test User') + } + return ( +
+ {cached + ? ( + + ) + : ( + + )} +
+ ) + }, +})) + +vi.mock('@/app/components/base/new-audio-button', () => ({ + default: () => , +})) + +vi.mock('@/app/components/base/chat/chat/log', () => ({ + default: () => , +})) + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(() => ({ appId: 'test-app' })), + usePathname: vi.fn(() => '/apps/test-app'), +})) + +const makeChatConfig = (overrides: Partial = {}): ChatConfig => ({ + opening_statement: '', + pre_prompt: '', + prompt_type: 'simple' as ChatConfig['prompt_type'], + user_input_form: [], + dataset_query_variable: '', + more_like_this: { enabled: false }, + suggested_questions_after_answer: { enabled: false }, + speech_to_text: { enabled: false }, + text_to_speech: { enabled: false }, + retriever_resource: { enabled: false }, + sensitive_word_avoidance: { enabled: false }, + agent_mode: { enabled: false, tools: [] }, + dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'], + system_parameters: { + audio_file_size_limit: 10, + file_size_limit: 10, + image_file_size_limit: 10, + video_file_size_limit: 10, + workflow_file_upload_limit: 10, + }, + supportFeedback: false, + supportAnnotation: false, + ...overrides, +} as ChatConfig) + +const mockContextValue: ChatContextValue = { + chatList: [], + config: makeChatConfig({ supportFeedback: true }), + onFeedback: vi.fn().mockResolvedValue(undefined), + onRegenerate: vi.fn(), + onAnnotationAdded: vi.fn(), + onAnnotationEdited: vi.fn(), + onAnnotationRemoved: vi.fn(), +} + +vi.mock('../context', () => ({ + useChatContext: () => mockContextValue, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: mockT, + }), +})) + +type OperationProps = { + item: ChatItem + question: string + index: number + showPromptLog?: boolean + maxSize: number + contentWidth: number + hasWorkflowProcess: boolean + noChatInput?: boolean +} + +const baseItem: ChatItem = { + id: 'msg-1', + content: 'Hello world', + isAnswer: true, +} + +const baseProps: OperationProps = { + item: baseItem, + question: 'What is this?', + index: 0, + maxSize: 500, + contentWidth: 300, + hasWorkflowProcess: false, +} + +describe('Operation', () => { + const renderOperation = (props = baseProps) => { + return render( +
+ +
, + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockContextValue.config = makeChatConfig({ supportFeedback: true }) + mockContextValue.onFeedback = vi.fn().mockResolvedValue(undefined) + mockContextValue.onRegenerate = vi.fn() + mockContextValue.onAnnotationAdded = vi.fn() + mockContextValue.onAnnotationEdited = vi.fn() + mockContextValue.onAnnotationRemoved = vi.fn() + mockProviderContext.plan.usage.annotatedResponse = 0 + mockProviderContext.enableBilling = false + mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } }) + }) + + describe('Rendering', () => { + it('should hide action buttons for opening statements', () => { + const item = { ...baseItem, isOpeningStatement: true } + renderOperation({ ...baseProps, item }) + expect(screen.queryByTestId('operation-actions')).not.toBeInTheDocument() + }) + + it('should show copy and regenerate buttons', () => { + renderOperation() + expect(screen.getByTestId('copy-btn')).toBeInTheDocument() + expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument() + }) + + it('should hide regenerate button when noChatInput is true', () => { + renderOperation({ ...baseProps, noChatInput: true }) + expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument() + }) + + it('should show TTS button when text_to_speech is enabled', () => { + mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } }) + renderOperation() + expect(screen.getByTestId('audio-btn')).toBeInTheDocument() + }) + + it('should show annotation button when config supports it', () => { + mockContextValue.config = makeChatConfig({ + supportAnnotation: true, + annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + }) + renderOperation() + expect(screen.getByTestId('annotation-ctrl')).toBeInTheDocument() + }) + + it('should show prompt log when showPromptLog is true', () => { + renderOperation({ ...baseProps, showPromptLog: true }) + expect(screen.getByTestId('log-btn')).toBeInTheDocument() + }) + + it('should not show prompt log for opening statements', () => { + const item = { ...baseItem, isOpeningStatement: true } + renderOperation({ ...baseProps, item, showPromptLog: true }) + expect(screen.queryByTestId('log-btn')).not.toBeInTheDocument() + }) + }) + + describe('Copy functionality', () => { + it('should copy content on copy click', async () => { + const user = userEvent.setup() + renderOperation() + await user.click(screen.getByTestId('copy-btn')) + expect(copy).toHaveBeenCalledWith('Hello world') + }) + + it('should aggregate agent_thoughts for copy content', async () => { + const user = userEvent.setup() + const item: ChatItem = { + ...baseItem, + content: 'ignored', + agent_thoughts: [ + { id: '1', thought: 'Hello ', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 0 }, + { id: '2', thought: 'World', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 1 }, + ], + } + renderOperation({ ...baseProps, item }) + await user.click(screen.getByTestId('copy-btn')) + expect(copy).toHaveBeenCalledWith('Hello World') + }) + }) + + describe('Regenerate', () => { + it('should call onRegenerate on regenerate click', async () => { + const user = userEvent.setup() + renderOperation() + await user.click(screen.getByTestId('regenerate-btn')) + expect(mockContextValue.onRegenerate).toHaveBeenCalledWith(baseItem) + }) + }) + + describe('Hiding controls with humanInputFormDataList', () => { + it('should hide TTS/copy/annotation when humanInputFormDataList is present', () => { + mockContextValue.config = makeChatConfig({ + supportFeedback: false, + text_to_speech: { enabled: true }, + supportAnnotation: true, + annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + }) + const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem + renderOperation({ ...baseProps, item }) + expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument() + expect(screen.queryByTestId('copy-btn')).not.toBeInTheDocument() + }) + }) + + describe('User feedback (no annotation support)', () => { + beforeEach(() => { + mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: false }) + }) + + it('should show like/dislike buttons', () => { + renderOperation() + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument() + expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument() + }) + + it('should call onFeedback with like on like click', async () => { + const user = userEvent.setup() + renderOperation() + const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + await user.click(thumbUp) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + }) + + it('should open feedback modal on dislike click', async () => { + const user = userEvent.setup() + renderOperation() + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should submit dislike feedback from modal', async () => { + const user = userEvent.setup() + renderOperation() + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + const textarea = screen.getByRole('textbox') + await user.type(textarea, 'Bad response') + const confirmBtn = screen.getByText(/submit/i) + await user.click(confirmBtn) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: 'Bad response' }) + }) + + it('should cancel feedback modal', async () => { + const user = userEvent.setup() + renderOperation() + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + expect(screen.getByRole('textbox')).toBeInTheDocument() + const cancelBtn = screen.getByText(/cancel/i) + await user.click(cancelBtn) + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should show existing like feedback and allow undo', async () => { + const user = userEvent.setup() + const item = { ...baseItem, feedback: { rating: 'like' as const } } + renderOperation({ ...baseProps, item }) + const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + await user.click(thumbUp) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should show existing dislike feedback and allow undo', async () => { + const user = userEvent.setup() + const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'bad' } } + renderOperation({ ...baseProps, item }) + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should undo like when already liked', async () => { + const user = userEvent.setup() + renderOperation() + // First click to like + const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + await user.click(thumbUp) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + + // Second click to undo - re-query as it might be a different node + const thumbUpUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + await user.click(thumbUpUndo) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should undo dislike when already disliked', async () => { + const user = userEvent.setup() + renderOperation() + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + const submitBtn = screen.getByText(/submit/i) + await user.click(submitBtn) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: '' }) + + // Re-query for undo + const thumbDownUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDownUndo) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should show tooltip with dislike and content', () => { + const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'Too slow' } } + renderOperation({ ...baseProps, item }) + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument() + }) + + it('should show tooltip with only rating', () => { + const item = { ...baseItem, feedback: { rating: 'like' as const } } + renderOperation({ ...baseProps, item }) + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument() + }) + + it('should not show feedback bar for opening statements', () => { + const item = { ...baseItem, isOpeningStatement: true } + renderOperation({ ...baseProps, item }) + expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument() + }) + + it('should not show user feedback bar when humanInputFormDataList is present', () => { + const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem + renderOperation({ ...baseProps, item }) + expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument() + }) + + it('should not call feedback when supportFeedback is disabled', async () => { + mockContextValue.config = makeChatConfig({ supportFeedback: false }) + mockContextValue.onFeedback = undefined + renderOperation() + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBe(0) + }) + }) + + describe('Admin feedback (with annotation support)', () => { + beforeEach(() => { + mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true }) + }) + + it('should show admin like/dislike buttons', () => { + renderOperation() + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(1) + expect(bar.querySelectorAll('.i-ri-thumb-down-line').length).toBeGreaterThanOrEqual(1) + }) + + it('should call onFeedback with like for admin', async () => { + const user = userEvent.setup() + renderOperation() + const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line') + const adminThumb = thumbs[thumbs.length - 1].closest('button')! + await user.click(adminThumb) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + }) + + it('should open feedback modal on admin dislike click', async () => { + const user = userEvent.setup() + renderOperation() + const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line') + const adminThumb = thumbs[thumbs.length - 1].closest('button')! + await user.click(adminThumb) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should show user feedback read-only in admin bar when user has liked', () => { + const item = { ...baseItem, feedback: { rating: 'like' as const } } + renderOperation({ ...baseProps, item }) + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(2) + }) + + it('should show separator in admin bar when user has feedback', () => { + const item = { ...baseItem, feedback: { rating: 'dislike' as const } } + renderOperation({ ...baseProps, item }) + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument() + }) + + it('should show existing admin like feedback and allow undo', async () => { + const user = userEvent.setup() + const item = { ...baseItem, adminFeedback: { rating: 'like' as const } } + renderOperation({ ...baseProps, item }) + const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + await user.click(thumbUp) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should show existing admin dislike and allow undo', async () => { + const user = userEvent.setup() + const item = { ...baseItem, adminFeedback: { rating: 'dislike' as const } } + renderOperation({ ...baseProps, item }) + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should undo admin like when already liked', async () => { + const user = userEvent.setup() + renderOperation() + const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line') + const adminThumb = thumbs[thumbs.length - 1].closest('button')! + await user.click(adminThumb) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + + const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line') + const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')! + await user.click(adminThumbUndo) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should undo admin dislike when already disliked', async () => { + const user = userEvent.setup() + renderOperation() + const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line') + const adminThumb = thumbs[thumbs.length - 1].closest('button')! + await user.click(adminThumb) + const submitBtn = screen.getByText(/submit/i) + await user.click(submitBtn) + + const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line') + const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')! + await user.click(adminThumbUndo) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + }) + + it('should not show admin feedback bar when humanInputFormDataList is present', () => { + const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem + renderOperation({ ...baseProps, item }) + expect(screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length).toBe(0) + }) + }) + + describe('Positioning and layout', () => { + it('should position right when operationWidth < maxSize', () => { + renderOperation({ ...baseProps, maxSize: 500 }) + const bar = screen.getByTestId('operation-bar') + expect(bar.style.left).toBeTruthy() + }) + + it('should position bottom when operationWidth >= maxSize', () => { + renderOperation({ ...baseProps, maxSize: 1 }) + const bar = screen.getByTestId('operation-bar') + expect(bar.style.left).toBeFalsy() + }) + + it('should apply workflow process class when hasWorkflowProcess is true', () => { + renderOperation({ ...baseProps, hasWorkflowProcess: true }) + const bar = screen.getByTestId('operation-bar') + expect(bar.className).toContain('-bottom-4') + }) + + it('should calculate width correctly for all features combined', () => { + mockContextValue.config = makeChatConfig({ + text_to_speech: { enabled: true }, + supportAnnotation: true, + annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + supportFeedback: true, + }) + const item = { ...baseItem, feedback: { rating: 'like' as const }, adminFeedback: { rating: 'dislike' as const } } + renderOperation({ ...baseProps, item, showPromptLog: true }) + const bar = screen.getByTestId('operation-bar') + expect(bar).toBeInTheDocument() + }) + + it('should show separator when user has feedback in admin mode', () => { + mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true }) + const item = { ...baseItem, feedback: { rating: 'like' as const } } + renderOperation({ ...baseProps, item }) + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument() + }) + + it('should handle missing translation fallbacks in buildFeedbackTooltip', () => { + // Mock t to return null for specific keys + mockT.mockImplementation((key: string): string => { + if (key.includes('Rate') || key.includes('like')) + return '' // Safe string fallback + + return key + }) + + renderOperation() + expect(screen.getByTestId('operation-bar')).toBeInTheDocument() + + // Reset to default behavior + mockT.mockImplementation(key => key) + }) + }) + + describe('Annotation integration', () => { + beforeEach(() => { + mockContextValue.config = makeChatConfig({ + supportAnnotation: true, + annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + appId: 'test-app', + }) + }) + + it('should add annotation via annotation ctrl button', async () => { + const user = userEvent.setup() + renderOperation() + const addBtn = screen.getByTestId('annotation-add-btn') + await user.click(addBtn) + expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('ann-new', 'Test User', 'What is this?', 'Hello world', 0) + }) + + it('should show annotation full modal when limit reached', async () => { + const user = userEvent.setup() + mockProviderContext.enableBilling = true + mockProviderContext.plan.usage.annotatedResponse = 100 + renderOperation() + const addBtn = screen.getByTestId('annotation-add-btn') + await user.click(addBtn) + expect(mockSetShowAnnotationFullModal).toHaveBeenCalled() + expect(mockAddAnnotation).not.toHaveBeenCalled() + }) + + it('should open edit reply modal when cached annotation exists', async () => { + const user = userEvent.setup() + const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + renderOperation({ ...baseProps, item }) + const editBtn = screen.getByTestId('annotation-edit-btn') + await user.click(editBtn) + expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument() + }) + + it('should call onAnnotationEdited from edit reply modal', async () => { + const user = userEvent.setup() + const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + renderOperation({ ...baseProps, item }) + const editBtn = screen.getByTestId('annotation-edit-btn') + await user.click(editBtn) + await user.click(screen.getByTestId('modal-edit')) + expect(mockContextValue.onAnnotationEdited).toHaveBeenCalledWith('eq', 'ea', 0) + }) + + it('should call onAnnotationAdded from edit reply modal', async () => { + const user = userEvent.setup() + const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + renderOperation({ ...baseProps, item }) + const editBtn = screen.getByTestId('annotation-edit-btn') + await user.click(editBtn) + await user.click(screen.getByTestId('modal-add')) + expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('a1', 'author', 'eq', 'ea', 0) + }) + + it('should call onAnnotationRemoved from edit reply modal', async () => { + const user = userEvent.setup() + const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + renderOperation({ ...baseProps, item }) + const editBtn = screen.getByTestId('annotation-edit-btn') + await user.click(editBtn) + await user.click(screen.getByTestId('modal-remove')) + expect(mockContextValue.onAnnotationRemoved).toHaveBeenCalledWith(0) + }) + + it('should close edit reply modal via onHide', async () => { + const user = userEvent.setup() + const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + renderOperation({ ...baseProps, item }) + const editBtn = screen.getByTestId('annotation-edit-btn') + await user.click(editBtn) + expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument() + await user.click(screen.getByTestId('modal-hide')) + expect(screen.queryByTestId('edit-reply-modal')).not.toBeInTheDocument() + }) + }) + + describe('TTS audio button', () => { + beforeEach(() => { + mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true, voice: 'test-voice' } }) + }) + + it('should show audio play button when TTS enabled', () => { + renderOperation() + expect(screen.getByTestId('audio-btn')).toBeInTheDocument() + }) + + it('should not show audio button for humanInputFormDataList', () => { + const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem + renderOperation({ ...baseProps, item }) + expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument() + }) + }) + + describe('Edge cases', () => { + it('should handle feedback content with only whitespace', async () => { + const user = userEvent.setup() + mockContextValue.config = makeChatConfig({ supportFeedback: true }) + renderOperation() + const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + await user.click(thumbDown) + const textarea = screen.getByRole('textbox') + await user.type(textarea, ' ') + const confirmBtn = screen.getByText(/submit/i) + await user.click(confirmBtn) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: ' ' }) + }) + + it('should handle missing onFeedback callback gracefully', async () => { + mockContextValue.onFeedback = undefined + mockContextValue.config = makeChatConfig({ supportFeedback: true }) + renderOperation() + const bar = screen.getByTestId('operation-bar') + expect(bar.querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument() + }) + + it('should handle empty agent_thoughts array', async () => { + const user = userEvent.setup() + const item: ChatItem = { ...baseItem, agent_thoughts: [] } + renderOperation({ ...baseProps, item }) + await user.click(screen.getByTestId('copy-btn')) + expect(copy).toHaveBeenCalledWith('Hello world') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index 4acf107232..f0d077975c 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -3,12 +3,6 @@ import type { ChatItem, Feedback, } from '../../types' -import { - RiClipboardLine, - RiResetLeftLine, - RiThumbDownLine, - RiThumbUpLine, -} from '@remixicon/react' import copy from 'copy-to-clipboard' import { memo, @@ -127,20 +121,10 @@ const Operation: FC = ({ } const handleLikeClick = (target: 'user' | 'admin') => { - const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating - if (currentRating === 'like') { - handleFeedback(null, undefined, target) - return - } handleFeedback('like', undefined, target) } const handleDislikeClick = (target: 'user' | 'admin') => { - const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating - if (currentRating === 'dislike') { - handleFeedback(null, undefined, target) - return - } setFeedbackTarget(target) setIsShowFeedbackModal(true) } @@ -186,6 +170,7 @@ const Operation: FC = ({ !hasWorkflowProcess && positionRight && '!top-[9px]', )} style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}} + data-testid="operation-bar" > {shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
= ({ onClick={() => handleFeedback(null, undefined, 'user')} > {displayUserFeedback?.rating === 'like' - ? - : } + ?
+ :
} ) @@ -215,13 +200,13 @@ const Operation: FC = ({ state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} onClick={() => handleLikeClick('user')} > - +
handleDislikeClick('user')} > - +
)} @@ -242,12 +227,12 @@ const Operation: FC = ({ {displayUserFeedback.rating === 'like' ? ( - +
) : ( - +
)} @@ -266,8 +251,8 @@ const Operation: FC = ({ onClick={() => handleFeedback(null, undefined, 'admin')} > {adminLocalFeedback?.rating === 'like' - ? - : } + ?
+ :
} ) @@ -281,7 +266,7 @@ const Operation: FC = ({ state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} onClick={() => handleLikeClick('admin')} > - +
= ({ state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} onClick={() => handleDislikeClick('admin')} > - +
@@ -305,7 +290,7 @@ const Operation: FC = ({
)} {!isOpeningStatement && ( -
+
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && ( = ({ /> )} {!humanInputFormDataList?.length && ( - { - copy(content) - Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) - }} + { + copy(content) + Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) + }} + data-testid="copy-btn" > - +
)} {!noChatInput && ( - onRegenerate?.(item)}> - + onRegenerate?.(item)} data-testid="regenerate-btn"> +
)} {config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && ( @@ -366,7 +353,7 @@ const Operation: FC = ({ >
-
-
+
handleThemeChange('light')} + data-testid="light-theme-container" >
- +
-
+
handleThemeChange('dark')} + data-testid="dark-theme-container" >
- +
diff --git a/web/app/components/base/timezone-label/index.spec.tsx b/web/app/components/base/timezone-label/index.spec.tsx new file mode 100644 index 0000000000..c43aa61936 --- /dev/null +++ b/web/app/components/base/timezone-label/index.spec.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import TimezoneLabel from './index' + +describe('TimezoneLabel', () => { + it('should render correctly with various timezones', () => { + const { rerender } = render() + const label = screen.getByTestId('timezone-label') + expect(label).toHaveTextContent('UTC+0') + expect(label).toHaveAttribute('title', 'Timezone: UTC (UTC+0)') + + rerender() + expect(label).toHaveTextContent('UTC+8') + expect(label).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)') + + rerender() + // New York is UTC-5 or UTC-4 depending on DST. + // dayjs handles this, we just check it renders some offset. + expect(label.textContent).toMatch(/UTC[-+]\d+/) + }) + + it('should apply correct styling for inline prop', () => { + render() + expect(screen.getByTestId('timezone-label')).toHaveClass('text-text-quaternary') + }) + + it('should apply custom className', () => { + render() + expect(screen.getByTestId('timezone-label')).toHaveClass('custom-test-class') + }) +}) diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx index f614280b3e..bb4355f338 100644 --- a/web/app/components/base/timezone-label/index.tsx +++ b/web/app/components/base/timezone-label/index.tsx @@ -43,11 +43,12 @@ const TimezoneLabel: React.FC = ({ return ( {offsetStr} diff --git a/web/app/components/base/tooltip/content.spec.tsx b/web/app/components/base/tooltip/content.spec.tsx new file mode 100644 index 0000000000..314c773ce1 --- /dev/null +++ b/web/app/components/base/tooltip/content.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ToolTipContent } from './content' + +describe('ToolTipContent', () => { + it('should render children correctly', () => { + render( + + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content')).toBeInTheDocument() + expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text') + expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument() + expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument() + }) + + it('should render title when provided', () => { + render( + + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title') + }) + + it('should render action when provided', () => { + render( + Action Text}> + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text') + }) + + it('should handle action click', async () => { + const user = userEvent.setup() + const handleActionClick = vi.fn() + render( + Action Text}> + Tooltip body text + , + ) + + await user.click(screen.getByText('Action Text')) + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/tooltip/content.tsx b/web/app/components/base/tooltip/content.tsx index 1879e077e5..a5a31a2a5c 100644 --- a/web/app/components/base/tooltip/content.tsx +++ b/web/app/components/base/tooltip/content.tsx @@ -11,12 +11,12 @@ export const ToolTipContent: FC = ({ children, }) => { return ( -
+
{!!title && ( -
{title}
+
{title}
)} -
{children}
- {!!action &&
{action}
} +
{children}
+ {!!action &&
{action}
}
) } diff --git a/web/app/components/base/video-gallery/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/VideoPlayer.spec.tsx new file mode 100644 index 0000000000..04d9ccc4c8 --- /dev/null +++ b/web/app/components/base/video-gallery/VideoPlayer.spec.tsx @@ -0,0 +1,262 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import VideoPlayer from './VideoPlayer' + +describe('VideoPlayer', () => { + const mockSrc = 'video.mp4' + const mockSrcs = ['video1.mp4', 'video2.mp4'] + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + + // Mock HTMLVideoElement methods + window.HTMLVideoElement.prototype.play = vi.fn().mockResolvedValue(undefined) + window.HTMLVideoElement.prototype.pause = vi.fn() + window.HTMLVideoElement.prototype.load = vi.fn() + window.HTMLVideoElement.prototype.requestFullscreen = vi.fn().mockResolvedValue(undefined) + + // Mock document methods + document.exitFullscreen = vi.fn().mockResolvedValue(undefined) + + // Mock offsetWidth to avoid smallSize mode by default + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 500, + }) + + // Define properties on HTMLVideoElement prototype + Object.defineProperty(window.HTMLVideoElement.prototype, 'duration', { + configurable: true, + get() { return 100 }, + }) + + // Use a descriptor check to avoid re-defining if it exists + if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'currentTime')) { + Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', { + configurable: true, + // eslint-disable-next-line ts/no-explicit-any + get() { return (this as any)._currentTime || 0 }, + // eslint-disable-next-line ts/no-explicit-any + set(v) { (this as any)._currentTime = v }, + }) + } + + if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'volume')) { + Object.defineProperty(window.HTMLVideoElement.prototype, 'volume', { + configurable: true, + // eslint-disable-next-line ts/no-explicit-any + get() { return (this as any)._volume || 1 }, + // eslint-disable-next-line ts/no-explicit-any + set(v) { (this as any)._volume = v }, + }) + } + + if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'muted')) { + Object.defineProperty(window.HTMLVideoElement.prototype, 'muted', { + configurable: true, + // eslint-disable-next-line ts/no-explicit-any + get() { return (this as any)._muted || false }, + // eslint-disable-next-line ts/no-explicit-any + set(v) { (this as any)._muted = v }, + }) + } + }) + + describe('Rendering', () => { + it('should render with single src', () => { + render() + const video = screen.getByTestId('video-element') as HTMLVideoElement + expect(video.src).toContain(mockSrc) + }) + + it('should render with multiple srcs', () => { + render() + const sources = screen.getByTestId('video-element').querySelectorAll('source') + expect(sources).toHaveLength(2) + expect(sources[0].src).toContain(mockSrcs[0]) + expect(sources[1].src).toContain(mockSrcs[1]) + }) + }) + + describe('Interactions', () => { + it('should toggle play/pause on button click', async () => { + const user = userEvent.setup() + render() + const playPauseBtn = screen.getByTestId('video-play-pause-button') + + await user.click(playPauseBtn) + expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled() + + await user.click(playPauseBtn) + expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled() + }) + + it('should toggle mute on button click', async () => { + const user = userEvent.setup() + render() + const muteBtn = screen.getByTestId('video-mute-button') + + await user.click(muteBtn) + expect(muteBtn).toBeInTheDocument() + }) + + it('should toggle fullscreen on button click', async () => { + const user = userEvent.setup() + render() + const fullscreenBtn = screen.getByTestId('video-fullscreen-button') + + await user.click(fullscreenBtn) + expect(window.HTMLVideoElement.prototype.requestFullscreen).toHaveBeenCalled() + + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get() { return {} }, + }) + await user.click(fullscreenBtn) + expect(document.exitFullscreen).toHaveBeenCalled() + + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get() { return null }, + }) + }) + + it('should handle video metadata and time updates', () => { + render() + const video = screen.getByTestId('video-element') as HTMLVideoElement + + fireEvent(video, new Event('loadedmetadata')) + expect(screen.getByTestId('video-time-display')).toHaveTextContent('00:00 / 01:40') + + Object.defineProperty(video, 'currentTime', { value: 30, configurable: true }) + fireEvent(video, new Event('timeupdate')) + expect(screen.getByTestId('video-time-display')).toHaveTextContent('00:30 / 01:40') + }) + + it('should handle video end', async () => { + const user = userEvent.setup() + render() + const video = screen.getByTestId('video-element') + const playPauseBtn = screen.getByTestId('video-play-pause-button') + + await user.click(playPauseBtn) + fireEvent(video, new Event('ended')) + + expect(playPauseBtn).toBeInTheDocument() + }) + + it('should show/hide controls on mouse move and timeout', () => { + vi.useFakeTimers() + render() + const container = screen.getByTestId('video-player-container') + + fireEvent.mouseMove(container) + fireEvent.mouseMove(container) // Trigger clearTimeout + + act(() => { + vi.advanceTimersByTime(3001) + }) + vi.useRealTimers() + }) + + it('should handle progress bar interactions', async () => { + const user = userEvent.setup() + render() + const progressBar = screen.getByTestId('video-progress-bar') + const video = screen.getByTestId('video-element') as HTMLVideoElement + + vi.spyOn(progressBar, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 100, + top: 0, + right: 100, + bottom: 10, + height: 10, + x: 0, + y: 0, + toJSON: () => { }, + } as DOMRect) + + // Hover + fireEvent.mouseMove(progressBar, { clientX: 50 }) + expect(screen.getByTestId('video-hover-time')).toHaveTextContent('00:50') + fireEvent.mouseLeave(progressBar) + expect(screen.queryByTestId('video-hover-time')).not.toBeInTheDocument() + + // Click + await user.click(progressBar) + // Note: user.click calculates clientX based on element position, but we mocked getBoundingClientRect + // RTL fireEvent is more direct for coordinate-based tests + fireEvent.click(progressBar, { clientX: 75 }) + expect(video.currentTime).toBe(75) + + // Drag + fireEvent.mouseDown(progressBar, { clientX: 20 }) + expect(video.currentTime).toBe(20) + fireEvent.mouseMove(document, { clientX: 40 }) + expect(video.currentTime).toBe(40) + fireEvent.mouseUp(document) + fireEvent.mouseMove(document, { clientX: 60 }) + expect(video.currentTime).toBe(40) + }) + + it('should handle volume slider change', () => { + render() + const volumeSlider = screen.getByTestId('video-volume-slider') + const video = screen.getByTestId('video-element') as HTMLVideoElement + + vi.spyOn(volumeSlider, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 100, + top: 0, + right: 100, + bottom: 10, + height: 10, + x: 0, + y: 0, + toJSON: () => { }, + } as DOMRect) + + // Click + fireEvent.click(volumeSlider, { clientX: 50 }) + expect(video.volume).toBe(0.5) + + // MouseDown and Drag + fireEvent.mouseDown(volumeSlider, { clientX: 80 }) + expect(video.volume).toBe(0.8) + + fireEvent.mouseMove(document, { clientX: 90 }) + expect(video.volume).toBe(0.9) + + fireEvent.mouseUp(document) // Trigger cleanup + fireEvent.mouseMove(document, { clientX: 100 }) + expect(video.volume).toBe(0.9) // No change after mouseUp + }) + + it('should handle small size class based on offsetWidth', async () => { + render() + const playerContainer = screen.getByTestId('video-player-container') + + Object.defineProperty(playerContainer, 'offsetWidth', { value: 300, configurable: true }) + + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + await waitFor(() => { + expect(screen.queryByTestId('video-time-display')).not.toBeInTheDocument() + }) + + Object.defineProperty(playerContainer, 'offsetWidth', { value: 500, configurable: true }) + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + await waitFor(() => { + expect(screen.getByTestId('video-time-display')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/base/video-gallery/VideoPlayer.tsx b/web/app/components/base/video-gallery/VideoPlayer.tsx index 8adaf71f58..6b2d802863 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.tsx +++ b/web/app/components/base/video-gallery/VideoPlayer.tsx @@ -215,8 +215,8 @@ const VideoPlayer: React.FC = ({ src, srcs }) => { }, []) return ( -
-