Compare commits

...

27 Commits

Author SHA1 Message Date
yyh
d81abd8d91 Merge branch 'main' into fix/query-client-infra-improvements 2026-02-24 22:55:40 +08:00
akashseth-ifp
8761109a34 test(base): added test coverage to form components (#32436) 2026-02-24 22:30:35 +08:00
Saumya Talwani
00935fe526 test: add tests for base > image-uploader (#32416) 2026-02-24 21:29:28 +08:00
Saumya Talwani
0358925d7d test: add tests for some base components (#32415) 2026-02-24 21:08:57 +08:00
Poojan
b8fbd7b0f6 test: add unit tests for chat/embedded-chatbot components (#32361)
Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
2026-02-24 20:58:45 +08:00
akashseth-ifp
bcd5dd0f81 test(web): increase coverage for files in folder plugin-page and model-provider-page (#32377) 2026-02-24 20:57:47 +08:00
longway
a1991c51e4 fix: add explicit return type annotations to BaseVector abstract methods (#32516) 2026-02-24 21:17:55 +09:00
Poojan
b2fa6cb4d3 test: add unit tests for chat components (#32367) 2026-02-24 18:29:21 +08:00
akashseth-ifp
ad3a195734 test(web): increase test coverage for model-provider-page folder (#32374) 2026-02-24 18:28:12 +08:00
Tyson Cung
84533cbfe0 fix: resolve pyright bad-index errors in parser.py (#32507)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-24 17:29:17 +09:00
Saumya Talwani
0eaae4f573 test: added tests for some base components (#32370) 2026-02-24 16:22:43 +08:00
Saumya Talwani
9819f7d69c test: add tests for file-upload components (#32373)
Co-authored-by: sahil <sahil@infocusp.com>
2026-02-24 16:16:06 +08:00
不做了睡大觉
a040b9428d fix: correct type annotations in Langfuse trace entities to match SDK (#32498)
Co-authored-by: User <user@example.com>
2026-02-24 16:31:12 +09:00
Saumya Talwani
740d94c6ed test: add tests for some base components (#32356) 2026-02-24 14:35:23 +08:00
Poojan
657eeb65b8 test: add unit tests for base-components-part-2 (#32409) 2026-02-24 14:34:48 +08:00
Saumya Talwani
f923901d3f test: add tests for base > features (#32397)
Co-authored-by: sahil <sahil@infocusp.com>
2026-02-24 13:01:45 +08:00
akashseth-ifp
a0ddaed6d3 test(web): Fix failing web test in 'Web Tests' GitHub Action (#32481) 2026-02-24 13:01:30 +08:00
akashseth-ifp
2162cd1a69 test(web): increase test coverage for components inside header folder (#32392) 2026-02-24 12:44:10 +08:00
mahammadasim
0070891114 test: add unit tests for prompt editor's component picker block plugin. (#32412) 2026-02-24 12:42:57 +08:00
Poojan
6e531fe44f test: add unit tests for base-components part-3 (#32408) 2026-02-24 12:21:02 +08:00
J0su3Code
80f49367eb fix: add return type annotation to abstract _publish method (#32493)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-24 03:12:43 +09:00
Tyson Cung
7c60ad01d3 fix: add return type annotation to Moderation.validate_config abstract method (#32491)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-24 02:11:43 +09:00
Stella Miyako
57890eed25 refactor: fix opentelemetry histogram type assignment error (#32490)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-24 01:32:16 +09:00
木之本澪
737575d637 test: migrate Dataset/Document property tests to testcontainers (#32487)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-24 01:23:48 +09:00
木之本澪
f76ee7cfa4 fix: add return type annotation to BaseVector.create (#32475)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
2026-02-23 22:28:40 +09:00
akashseth-ifp
a0244d1390 test(web): add tests for model-provider-page files in header account-… (#32360) 2026-02-23 20:07:19 +08:00
akashseth-ifp
42af9d5438 test(web): add members-page account-setting specs and improve coverage (#32311) 2026-02-23 20:06:35 +08:00
326 changed files with 39414 additions and 982 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(<Alert {...defaultProps} />)
expect(screen.getByText(defaultProps.message)).toBeInTheDocument()
})
it('should render the info icon', () => {
render(<Alert {...defaultProps} />)
const icon = screen.getByTestId('info-icon')
expect(icon).toBeInTheDocument()
})
it('should render the close icon', () => {
render(<Alert {...defaultProps} />)
const closeIcon = screen.getByTestId('close-icon')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
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(<Alert {...defaultProps} className="my-custom-class" />)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('pointer-events-none', 'w-full')
})
it('should default type to info', () => {
render(<Alert {...defaultProps} />)
const gradientDiv = screen.getByTestId('alert-gradient')
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
})
it('should render with explicit type info', () => {
render(<Alert {...defaultProps} type="info" />)
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(<Alert {...defaultProps} message={msg} />)
expect(screen.getByText(msg)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHide when close button is clicked', () => {
const onHide = vi.fn()
render(<Alert {...defaultProps} onHide={onHide} />)
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(<Alert {...defaultProps} onHide={onHide} />)
fireEvent.click(screen.getByText(defaultProps.message))
expect(onHide).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should render with an empty message string', () => {
render(<Alert {...defaultProps} message="" />)
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(<Alert {...defaultProps} message={longMessage} />)
expect(screen.getByText(longMessage)).toBeInTheDocument()
})
})
})

View File

@@ -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<Props> = ({
<div
className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"
>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))} data-testid="alert-gradient">
</div>
<div className="flex h-6 w-6 items-center justify-center">
<RiInformation2Fill className="text-text-accent" />
<span className="i-ri-information-2-fill text-text-accent" data-testid="info-icon" />
</div>
<div className="p-1">
<div className="system-xs-regular text-text-secondary">
<div className="text-text-secondary system-xs-regular" data-testid="msg-container">
{message}
</div>
</div>
@@ -49,7 +45,7 @@ const Alert: React.FC<Props> = ({
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={onHide}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-icon" />
</div>
</div>
</div>

View File

@@ -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(<ImageInput />)
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(<ImageInput />)
const input = screen.getByTestId('image-input')
expect(input).toBeInTheDocument()
expect(input).toHaveClass('hidden')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<ImageInput className="my-custom-class" />)
expect(container.firstChild).toHaveClass('my-custom-class')
})
})
describe('User Interactions', () => {
it('should trigger file input click when browse button is clicked', () => {
render(<ImageInput />)
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(<ImageInput />)
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(<ImageInput onImageInput={onImageInput} />)
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(<ImageInput onImageInput={onImageInput} />)
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(<ImageInput />)
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(<ImageInput />)
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(<ImageInput />)
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(<ImageInput />)
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(<ImageInput />)
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(<ImageInput />)
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(<ImageInput />)
unmount()
expect(revokeObjectURLMock).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should not crash when onImageInput is not provided', async () => {
render(<ImageInput />)
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(<ImageInput />)
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')
})
})
})

View File

@@ -72,7 +72,8 @@ const ImageInput: FC<UploaderProps> = ({
const handleShowImage = () => {
if (isAnimatedImage) {
return (
<img src={inputImage?.url} alt="" />
// eslint-disable-next-line next/no-img-element
<img src={inputImage?.url} alt="" data-testid="animated-image" />
)
}
@@ -107,7 +108,7 @@ const ImageInput: FC<UploaderProps> = ({
<div className="mb-[2px] text-sm font-medium">
<span className="pointer-events-none">
{t('imageInput.dropImageHere', { ns: 'common' })}
&nbsp;
&nbsp;
</span>
<button type="button" className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>{t('imageInput.browse', { ns: 'common' })}</button>
<input
@@ -117,6 +118,7 @@ const ImageInput: FC<UploaderProps> = ({
onClick={e => ((e.target as HTMLInputElement).value = '')}
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
onChange={handleLocalFileInput}
data-testid="image-input"
/>
</div>
<div className="pointer-events-none">{t('imageInput.supportedFormats', { ns: 'common' })}</div>

View File

@@ -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<HTMLDivElement> => ({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { files: [] as unknown as FileList },
...overrides,
} as unknown as React.DragEvent<HTMLDivElement>)
describe('useDraggableUploader', () => {
let setImageFn: ReturnType<typeof vi.fn<(file: File) => 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)
})
})
})

View File

@@ -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<string, EventListener[]> = {}
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> = {}): 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 }) => (
<div data-testid="mock-cropper">
<button
type="button"
data-testid="trigger-crop"
onClick={() => onCropComplete(
{ x: 0, y: 0, width: 100, height: 100 },
{ x: 0, y: 0, width: 100, height: 100 },
)}
>
Trigger Crop
</button>
</div>
),
}))
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<Document['createElement']>) => {
if (args[0] === 'canvas') {
const nextCanvas = canvases.shift()
if (!nextCanvas)
throw new Error('Unexpected canvas creation')
return nextCanvas as ReturnType<Document['createElement']>
}
return originalCreateElement(...args)
})
}
const renderPicker = () => {
const onSelect = vi.fn()
const onClose = vi.fn()
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} />)
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()
})
})
})

View File

@@ -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<string, EventListener[]> = {}
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<typeof vi.fn>
toBlobMock: ReturnType<typeof vi.fn>
}
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<Document['createElement']>) => {
if (args[0] === 'canvas') {
const nextCanvas = canvases.shift()
if (!nextCanvas)
throw new Error('Unexpected canvas creation')
return nextCanvas as ReturnType<Document['createElement']>
}
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<FileReader>) => void) | null = null
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
readAsArrayBuffer(_blob: Blob) {
const errorEvent = new ProgressEvent('error') as ProgressEvent<FileReader>
setTimeout(() => {
this.onerror?.(errorEvent)
}, 0)
}
} as unknown as typeof FileReader
await expect(checkIsAnimatedImage(file)).rejects.toBeInstanceOf(ProgressEvent)
})
})
})

View File

@@ -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(<AppUnavailable />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should render the error code in a heading', () => {
render(<AppUnavailable />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveTextContent(/404/)
})
it('should render the default unavailable message', () => {
render(<AppUnavailable />)
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display custom error code', () => {
render(<AppUnavailable code={500} />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500')
})
it('should accept string error code', () => {
render(<AppUnavailable code="403" />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403')
})
it('should apply custom className', () => {
const { container } = render(<AppUnavailable className="my-custom" />)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
const { container } = render(<AppUnavailable className="my-custom" />)
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(<AppUnavailable unknownReason="Custom error occurred" />)
expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument()
})
it('should display unknown error translation when isUnknownReason is true', () => {
render(<AppUnavailable isUnknownReason />)
expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument()
})
it('should prioritize unknownReason over isUnknownReason', () => {
render(<AppUnavailable isUnknownReason unknownReason="My custom reason" />)
expect(screen.getByText(/My custom reason/i)).toBeInTheDocument()
})
it('should show appUnavailable translation when isUnknownReason is false', () => {
render(<AppUnavailable isUnknownReason={false} />)
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render with code 0', () => {
render(<AppUnavailable code={0} />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0')
})
it('should render with an empty unknownReason and fall back to translation', () => {
render(<AppUnavailable unknownReason="" />)
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
})
})
})

View File

@@ -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 <div data-testid="audio-player" />
},
}))
describe('AudioGallery', () => {
afterEach(() => {
audioPlayerMock.mockClear()
vi.resetModules()
})
it('returns null when srcs array is empty', () => {
const { container } = render(<AudioGallery srcs={[]} />)
expect(container.firstChild).toBeNull()
expect(screen.queryByTestId('audio-player')).toBeNull()
})
it('returns null when all srcs are falsy', () => {
const { container } = render(<AudioGallery srcs={['', '', '']} />)
expect(container.firstChild).toBeNull()
expect(screen.queryByTestId('audio-player')).toBeNull()
})
it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => {
render(<AudioGallery srcs={['a.mp3', '', 'b.mp3']} />)
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(<AudioGallery srcs={['a.mp3']} />)
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
expect(root.className).toContain('my-3')
})
})

View File

@@ -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(<AutoHeightTextarea value="" onChange={vi.fn()} />)
const textarea = container.querySelector('textarea')
expect(textarea).toBeInTheDocument()
})
it('should render with placeholder when value is empty', () => {
render(<AutoHeightTextarea placeholder="Enter text" value="" onChange={vi.fn()} />)
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
})
it('should render with value', () => {
render(<AutoHeightTextarea value="Hello World" onChange={vi.fn()} />)
const textarea = screen.getByDisplayValue('Hello World')
expect(textarea).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to textarea', () => {
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} className="custom-class" />)
const textarea = container.querySelector('textarea')
expect(textarea).toHaveClass('custom-class')
})
it('should apply custom wrapperClassName to wrapper div', () => {
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} wrapperClassName="wrapper-class" />)
const wrapper = container.querySelector('div.relative')
expect(wrapper).toHaveClass('wrapper-class')
})
it('should apply minHeight and maxHeight styles to hidden div', () => {
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} minHeight={50} maxHeight={200} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} autoFocus />)
expect(focusSpy).toHaveBeenCalled()
focusSpy.mockRestore()
})
})
describe('User Interactions', () => {
it('should call onChange when textarea value changes', () => {
const handleChange = vi.fn()
render(<AutoHeightTextarea value="" onChange={handleChange} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyDown={handleKeyDown} />)
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(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyUp={handleKeyUp} />)
const textarea = screen.getByRole('textbox')
fireEvent.keyUp(textarea, { key: 'Enter' })
expect(handleKeyUp).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle empty string value', () => {
render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('')
})
it('should handle whitespace-only value', () => {
render(<AutoHeightTextarea value=" " onChange={vi.fn()} />)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue(' ')
})
it('should handle very long text (>10000 chars)', () => {
const longText = 'a'.repeat(10001)
render(<AutoHeightTextarea value={longText} onChange={vi.fn()} />)
const textarea = screen.getByDisplayValue(longText)
expect(textarea).toBeInTheDocument()
})
it('should handle newlines in value', () => {
const textWithNewlines = 'line1\nline2\nline3'
render(<AutoHeightTextarea value={textWithNewlines} onChange={vi.fn()} />)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue(textWithNewlines)
})
it('should handle special characters in value', () => {
const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?'
render(<AutoHeightTextarea value={specialChars} onChange={vi.fn()} />)
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(<AutoHeightTextarea ref={ref as React.RefObject<HTMLTextAreaElement>} 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(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={1} />)
expect(focusSpy).toHaveBeenCalledTimes(1)
rerender(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={2} />)
expect(focusSpy).toHaveBeenCalledTimes(2)
focusSpy.mockRestore()
})
it('should retry focus recursively when ref is not ready during autoFocus', async () => {
const delayedRef = {} as React.RefObject<HTMLTextAreaElement>
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(<AutoHeightTextarea ref={delayedRef} value="" onChange={vi.fn()} autoFocus />)
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')
})
})
})

View File

@@ -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(<Badge text="beta" />)
expect(screen.getByText(/beta/i)).toBeInTheDocument()
})
it('should render with children instead of text', () => {
render(<Badge><span>child content</span></Badge>)
expect(screen.getByText(/child content/i)).toBeInTheDocument()
})
it('should render with no text or children', () => {
const { container } = render(<Badge />)
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild).toHaveTextContent('')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<Badge text="test" className="my-custom" />)
const badge = container.firstChild as HTMLElement
expect(badge).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
const { container } = render(<Badge text="test" className="my-custom" />)
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(<Badge text="test" />)
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(<Badge text="test" uppercase={false} />)
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(<Badge text="test" hasRedCornerMark />)
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(<Badge text="test" />)
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
expect(mark).not.toBeInTheDocument()
})
it('should prioritize children over text', () => {
render(<Badge text="text content"><span>child wins</span></Badge>)
expect(screen.getByText(/child wins/i)).toBeInTheDocument()
expect(screen.queryByText(/text content/i)).not.toBeInTheDocument()
})
it('should render ReactNode as text prop', () => {
render(<Badge text={<strong>bold badge</strong>} />)
expect(screen.getByText(/bold badge/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render with empty string text', () => {
const { container } = render(<Badge text="" />)
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild).toHaveTextContent('')
})
it('should render with hasRedCornerMark false explicitly', () => {
const { container } = render(<Badge text="test" hasRedCornerMark={false} />)
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
expect(mark).not.toBeInTheDocument()
})
})
})

View File

@@ -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(<BlockInput value="" />)
const wrapper = screen.getByTestId('block-input')
expect(wrapper).toBeInTheDocument()
})
it('should render with initial value', () => {
const { container } = render(<BlockInput value="Hello World" />)
expect(container.textContent).toContain('Hello World')
})
it('should render variable highlights', () => {
render(<BlockInput value="Hello {{name}}" />)
const nameElement = screen.getByText('name')
expect(nameElement).toBeInTheDocument()
expect(nameElement.parentElement).toHaveClass('text-primary-600')
})
it('should render multiple variable highlights', () => {
render(<BlockInput value="{{foo}} and {{bar}}" />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
})
it('should display character count in footer when not readonly', () => {
render(<BlockInput value="Hello" />)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should hide footer in readonly mode', () => {
render(<BlockInput value="Hello" readonly />)
expect(screen.queryByText('5')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<BlockInput value="test" className="custom-class" />)
const innerContent = screen.getByTestId('block-input-content')
expect(innerContent).toHaveClass('custom-class')
})
it('should apply readonly prop with max height', () => {
render(<BlockInput value="test" readonly />)
const contentDiv = screen.getByTestId('block-input').firstChild as Element
expect(contentDiv).toHaveClass('max-h-[180px]')
})
it('should have default empty value', () => {
render(<BlockInput value="" />)
const contentDiv = screen.getByTestId('block-input')
expect(contentDiv).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should enter edit mode when clicked', async () => {
render(<BlockInput value="Hello" />)
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<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
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<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
render(<BlockInput value="initial" onConfirm={onConfirm} />)
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<typeof vi.fn>).mockReturnValue({
isValid: false,
errorMessageKey: 'invalidKey',
errorKey: 'test_key',
})
render(<BlockInput value="initial" onConfirm={onConfirm} />)
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(<BlockInput value="Hello" readonly />)
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(<BlockInput value="" />)
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(<BlockInput value="plain text" />)
expect(screen.getByText('plain text')).toBeInTheDocument()
})
it('should handle newlines in value', () => {
render(<BlockInput value="line1\nline2" />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
})
it('should handle multiple same variables', () => {
render(<BlockInput value="{{name}} and {{name}}" />)
const highlights = screen.getAllByText('name')
expect(highlights).toHaveLength(2)
})
it('should handle value with only variables', () => {
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
expect(screen.getByText('baz')).toBeInTheDocument()
})
it('should handle text adjacent to variables', () => {
render(<BlockInput value="prefix {{var}} suffix" />)
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'])
})
})

View File

@@ -63,7 +63,7 @@ const BlockInput: FC<IBlockInputProps> = ({
}, [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<IBlockInputProps> = ({
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
const TextAreaContentView = () => {
return (
<div className={cn(style, className)}>
<div className={cn(style, className)} data-testid="block-input-content">
{renderSafeContent(currentValue || '')}
</div>
)
@@ -121,7 +121,7 @@ const BlockInput: FC<IBlockInputProps> = ({
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
const textAreaContent = (
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
{isEditing
? (
<div className="h-full px-4 py-2">
@@ -134,10 +134,10 @@ const BlockInput: FC<IBlockInputProps> = ({
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)
}}
/>
</div>
@@ -147,7 +147,7 @@ const BlockInput: FC<IBlockInputProps> = ({
)
return (
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
{textAreaContent}
{/* footer */}
{!readonly && (

View File

@@ -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(<AddButton onClick={vi.fn()} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render an add icon', () => {
render(<AddButton onClick={vi.fn()} />)
const iconSpan = screen.getByTestId('add-button').querySelector('span')
expect(iconSpan).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
expect(container.firstChild).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
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(<AddButton onClick={onClick} />)
fireEvent.click(container.firstChild!)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should call onClick multiple times on repeated clicks', () => {
const onClick = vi.fn()
const { container } = render(<AddButton onClick={onClick} />)
fireEvent.click(container.firstChild!)
fireEvent.click(container.firstChild!)
fireEvent.click(container.firstChild!)
expect(onClick).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -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<Props> = ({
onClick,
}) => {
return (
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
<RiAddLine className="h-4 w-4 text-text-tertiary" />
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="add-button">
<span className="i-ri-add-line h-4 w-4 text-text-tertiary" />
</div>
)
}

View File

@@ -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(<SyncButton onClick={vi.fn()} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render a refresh icon', () => {
render(<SyncButton onClick={vi.fn()} />)
const iconSpan = screen.getByTestId('sync-button').querySelector('span')
expect(iconSpan).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
const clickableDiv = screen.getByTestId('sync-button')
expect(clickableDiv).toHaveClass('my-custom')
})
it('should retain base classes when custom className is applied', () => {
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
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(<SyncButton onClick={onClick} />)
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(<SyncButton onClick={onClick} />)
const clickableDiv = screen.getByTestId('sync-button')
fireEvent.click(clickableDiv)
fireEvent.click(clickableDiv)
fireEvent.click(clickableDiv)
expect(onClick).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -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<Props> = ({
}) => {
return (
<TooltipPlus popupContent={popupContent}>
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="sync-button">
<span className="i-ri-refresh-line h-4 w-4 text-text-tertiary" />
</div>
</TooltipPlus>
)

View File

@@ -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<EmblaEventName, EmblaListener[]>
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(
<Carousel orientation={orientation}>
<Carousel.Content data-testid="carousel-content">
<Carousel.Item>Slide 1</Carousel.Item>
<Carousel.Item>Slide 2</Carousel.Item>
<Carousel.Item>Slide 3</Carousel.Item>
</Carousel.Content>
<Carousel.Previous>Prev</Carousel.Previous>
<Carousel.Next>Next</Carousel.Next>
<Carousel.Dot>Dot</Carousel.Dot>
</Carousel>,
)
}
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<typeof useEmblaCarousel>,
)
})
// 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(
<Carousel opts={{ loop: true }} plugins={[plugin]}>
<Carousel.Content />
</Carousel>,
)
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(<InvalidConsumer />)).toThrowError(
'useCarousel must be used within a <Carousel />',
)
})
it('should render with disabled controls and no dots when embla api is undefined', () => {
mockedUseEmblaCarousel.mockReturnValue(
[mockCarouselRef, undefined] as unknown as ReturnType<typeof useEmblaCarousel>,
)
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()
})
})
})

View File

@@ -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 }) => (
<div data-testid={props['data-testid'] || 'markdown'} data-content={String(props.content)} className={props.className}>
{String(props.content)}
</div>
),
}))
// Mock Thought
vi.mock('@/app/components/base/chat/chat/thought', () => ({
default: ({ thought, isFinished }: IThoughtProps) => (
<div data-testid="thought-component" data-finished={isFinished}>
{thought.thought}
</div>
),
}))
// Mock FileList and Utils
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: FileEntity[] }) => (
<div data-testid="file-list-component">
{files.map(f => f.name).join(', ')}
</div>
),
}))
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(<AgentContent item={itemWithAnnotation as ChatItem} />)
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content')
})
it('renders content prop if provided and no annotation', () => {
render(<AgentContent item={mockItem} content="Direct Content" />)
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(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
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(<AgentContent item={itemWithThoughts as ChatItem} responding={true} />)
const thoughts = screen.getAllByTestId('thought-component')
expect(thoughts[0]).toHaveAttribute('data-finished', 'true')
expect(thoughts[1]).toHaveAttribute('data-finished', 'false')
rerender(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
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(<AgentContent item={itemWithFiles as ChatItem} />)
expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2')
})
it('renders nothing if no annotation, content, or thoughts', () => {
render(<AgentContent item={mockItem} />)
expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement()
})
})

View File

@@ -23,15 +23,29 @@ const AgentContent: FC<AgentContentProps> = ({
agent_thoughts,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
if (annotation?.logAnnotation) {
return (
<Markdown
content={annotation?.logAnnotation.content || ''}
data-testid="agent-content-markdown"
/>
)
}
return (
<div>
{content ? <Markdown content={content} /> : agent_thoughts?.map((thought, index) => (
<div key={index} className="px-2 py-1">
<div data-testid="agent-content-container">
{content ? (
<Markdown
content={content}
data-testid="agent-content-markdown"
/>
) : agent_thoughts?.map((thought, index) => (
<div key={index} className="px-2 py-1" data-testid="agent-thought-item">
{thought.thought && (
<Markdown content={thought.thought} />
<Markdown
content={thought.thought}
data-testid="agent-thought-markdown"
/>
)}
{/* {item.tool} */}
{/* perhaps not use tool */}

View File

@@ -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) => (
<div data-testid="basic-content-markdown" data-content={String(content)} className={className}>
{String(content)}
</div>
),
}))
describe('BasicContent', () => {
const mockItem = {
id: '1',
content: 'Hello World',
isAnswer: true,
}
it('renders content correctly', () => {
render(<BasicContent item={mockItem as ChatItem} />)
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(<BasicContent item={itemWithAnnotation as ChatItem} />)
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(<BasicContent item={itemWithUNC as ChatItem} />)
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(<BasicContent item={itemWithBackticks as ChatItem} />)
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(<BasicContent item={itemWithBackslashes as ChatItem} />)
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(<BasicContent item={errorItem as ChatItem} />)
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(<BasicContent item={itemWithNonStringContent as unknown as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '12345')
})
})

View File

@@ -15,8 +15,14 @@ const BasicContent: FC<BasicContentProps> = ({
content,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
if (annotation?.logAnnotation) {
return (
<Markdown
content={annotation?.logAnnotation.content || ''}
data-testid="basic-content-markdown"
/>
)
}
// 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<BasicContentProps> = ({
item.isError && '!text-[#F04438]',
)}
content={displayContent}
data-testid="basic-content-markdown"
/>
)
}

View File

@@ -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 }) => <div data-testid="mock-markdown">{content}</div>,
}))
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(
<ContentItem
content="Hello world"
formInputFields={[]}
inputs={{}}
onInputChange={mockOnInputChange}
/>,
)
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(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={mockFormInputFields}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
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(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={mockFormInputFields}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
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(
<ContentItem
content="{{#$output.unknown_field#}}"
formInputFields={mockFormInputFields}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
expect(container.firstChild).toBeNull()
})
it('should render nothing if input type is not supported', () => {
const { container } = render(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={[
{
type: 'text-input',
output_variable_name: 'user_bio',
default: {
type: 'constant',
value: '',
selector: [],
},
} as FormInputItem,
]}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument()
expect(container.querySelector('.py-3')?.textContent).toBe('')
})
})

View File

@@ -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"
/>
)}
</div>

View File

@@ -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: <div data-testid="child-content">Child Content</div>,
}
it('should render node title and children by default when not collapsible', () => {
render(<ContentWrapper {...defaultProps} />)
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(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={false} />)
// 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(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={true} />)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
const expandToggle = screen.getByTestId('expand-icon')
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
})

View File

@@ -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 (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
<div
className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}
data-testid="content-wrapper"
>
<div className="flex items-center gap-2 p-2">
{/* node icon */}
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
{/* node name */}
<div
className="system-sm-semibold-uppercase grow truncate text-text-primary"
className="grow truncate text-text-primary system-sm-semibold-uppercase"
title={nodeTitle}
>
{nodeTitle}
</div>
{showExpandIcon && (
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
<div
className="shrink-0 cursor-pointer"
onClick={handleToggleExpand}
data-testid="expand-icon"
>
{
isExpanded
? (
<RiArrowDownSLine className="size-4" />
<div className="i-ri-arrow-down-s-line size-4" />
)
: (
<RiArrowRightSLine className="size-4" />
<div className="i-ri-arrow-right-s-line size-4" />
)
}
</div>

View File

@@ -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(<ExecutedAction executedAction={executedAction} />)
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()
})
})

View File

@@ -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 (
<div className="flex flex-col gap-y-1 py-1">
<div className="flex flex-col gap-y-1 py-1" data-testid="executed-action">
<Divider className="mb-2 mt-1 w-[30px]" />
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
<TriggerAll className="size-3.5 shrink-0" />
<div className="flex items-center gap-x-1 text-text-tertiary system-xs-regular">
<div className="i-custom-vender-workflow-trigger-all size-3.5 shrink-0" />
<Trans
i18nKey="nodes.humanInput.userActions.triggered"
ns="workflow"
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
components={{ strong: <span className="text-text-secondary system-xs-medium"></span> }}
values={{ actionName: executedAction.id }}
/>
</div>

View File

@@ -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<typeof import('./utils')>()
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(<ExpirationTime expirationTime={1234567890} />)
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(<ExpirationTime expirationTime={1234567890} />)
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning')
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
})
})

View File

@@ -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 (
<div
data-testid="expiration-time"
className={cn(
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
'mt-1 flex items-center gap-x-1 text-text-tertiary system-xs-regular',
!isSameOrAfter && 'text-text-warning',
)}
>
@@ -28,13 +28,13 @@ const ExpirationTime = ({
isSameOrAfter
? (
<>
<RiTimeLine className="size-3.5" />
<div className="i-ri-time-line size-3.5" />
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
</>
)
: (
<>
<RiAlertFill className="size-3.5" />
<div className="i-ri-alert-fill size-3.5" />
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
</>
)

View File

@@ -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 }) => (
<div data-testid="mock-content-item">
{content}
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
</div>
),
}))
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(<HumanInputForm formData={mockFormData} />)
// 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(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
// 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>) => void
const submitPromise = new Promise<void>((resolve) => {
resolveSubmit = resolve
})
const mockOnSubmit = vi.fn().mockReturnValue(submitPromise)
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
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(<HumanInputForm formData={formDataWithoutDefaults as unknown as HumanInputFormData} />)
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(<HumanInputForm formData={formDataWithUnsupported} />)
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
})
})

View File

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

View File

@@ -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 }) => <div data-testid="mock-markdown">{content}</div>,
}))
describe('SubmittedContent', () => {
it('should render Markdown with the provided content', () => {
const content = '## Test Content'
render(<SubmittedContent content={content} />)
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
expect(screen.getByTestId('mock-markdown')).toHaveTextContent(content)
})
})

View File

@@ -9,7 +9,9 @@ const SubmittedContent = ({
content,
}: SubmittedContentProps) => {
return (
<Markdown content={content} />
<div data-testid="submitted-content">
<Markdown content={content} />
</div>
)
}

View File

@@ -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 }) => <div data-testid="mock-markdown">{content}</div>,
}))
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(<SubmittedHumanInputContent formData={mockFormData} />)
// 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()
})
})

View File

@@ -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<typeof import('@/context/app-context')>()
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(
<Tips
showEmailTip={true}
isEmailDebugMode={false}
showDebugModeTip={false}
/>,
)
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(
<Tips
showEmailTip={true}
isEmailDebugMode={true}
showDebugModeTip={false}
/>,
)
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
})
it('should render debug mode tip', () => {
render(
<Tips
showEmailTip={false}
isEmailDebugMode={false}
showDebugModeTip={true}
/>,
)
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(
<Tips
showEmailTip={false}
isEmailDebugMode={false}
showDebugModeTip={false}
/>,
)
expect(screen.queryByTestId('tips')).toBeEmptyDOMElement()
// Divider is outside of tips container, but within the fragment
expect(container.querySelector('.v-divider')).toBeDefined()
})
})

View File

@@ -20,12 +20,12 @@ const Tips = ({
return (
<>
<Divider className="!my-2 w-[30px]" />
<div className="space-y-1 pt-1">
<div className="space-y-1 pt-1" data-testid="tips">
{showEmailTip && !isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
<div className="text-text-secondary system-xs-regular">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
)}
{showEmailTip && isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">
<div className="text-text-secondary system-xs-regular">
<Trans
i18nKey="common.humanInputEmailTipInDebugMode"
ns="workflow"
@@ -34,7 +34,7 @@ const Tips = ({
/>
</div>
)}
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
{showDebugModeTip && <div className="text-text-warning system-xs-medium">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
</div>
</>
)

View File

@@ -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<typeof import('@/context/app-context')>()
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(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={true}
showDebugModeTip={true}
onSubmit={vi.fn()}
/>,
)
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(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
expect(screen.queryByTestId('expiration-time')).not.toBeInTheDocument()
})
it('should hide Tips when both tip flags are false', () => {
render(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={false}
showDebugModeTip={false}
onSubmit={vi.fn()}
/>,
)
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
})
it('should render different email tips based on debug mode', () => {
const { rerender } = render(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={true}
isEmailDebugMode={false}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
rerender(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={true}
isEmailDebugMode={true}
onSubmit={vi.fn()}
/>,
)
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(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
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(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
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>) => void
const handleSubmit = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
resolveSubmit = resolve
}))
const data = createMockFormData()
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
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(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
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(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
// 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(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
// initializeInputs is tested indirectly here.
// We can't easily assert the internal state of HumanInputForm, but we can verify it doesn't crash.
})
})
})

View File

@@ -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> = {},
): 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(<HumanInputFilledFormList humanInputFilledFormDataList={[]} />)
expect(screen.queryByText('Node Title')).not.toBeInTheDocument()
})
it('renders one form item', () => {
const data = [createFormData()]
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
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(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('renders wrapper container', () => {
const { container } = render(
<HumanInputFilledFormList humanInputFilledFormDataList={[createFormData()]} />,
)
expect(container.firstChild).toHaveClass('flex')
expect(container.firstChild).toHaveClass('flex-col')
})
})

View File

@@ -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 }) => (
<div data-testid="content-wrapper" data-nodetitle={nodeTitle}>
{children}
</div>
),
}))
vi.mock('./human-input-content/unsubmitted', () => ({
UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => (
<div data-testid="unsubmitted-content">
<span data-testid="email-tip">{showEmailTip ? 'true' : 'false'}</span>
<span data-testid="email-debug">{isEmailDebugMode ? 'true' : 'false'}</span>
<span data-testid="debug-tip">{showDebugModeTip ? 'true' : 'false'}</span>
</div>
),
}))
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(<HumanInputFormList humanInputFormDataList={[]} />)
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(
<HumanInputFormList
humanInputFormDataList={mockFormData as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
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(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
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(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
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(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
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(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
expect(screen.getByTestId('debug-tip')).toHaveTextContent('true')
})
})
})

View File

@@ -45,22 +45,28 @@ const HumanInputFormList = ({
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
return (
<div className="mt-2 flex flex-col gap-y-2">
<div
className="mt-2 flex flex-col gap-y-2"
data-testid="human-input-form-list"
>
{
filteredHumanInputFormDataList.map(formData => (
<ContentWrapper
<div
key={formData.form_id}
nodeTitle={formData.node_title}
data-testid="human-input-form-item"
>
<UnsubmittedHumanInputContent
key={formData.form_id}
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
<ContentWrapper
nodeTitle={formData.node_title}
>
<UnsubmittedHumanInputContent
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
</div>
))
}
</div>

View File

@@ -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(<More more={mockMoreData} />)
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(<More more={dataWithoutTPS} />)
expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument()
})
it('should render nothing inside container if more prop is missing', () => {
render(<More more={undefined} />)
const containerDiv = screen.getByTestId('more-container')
expect(containerDiv).toBeInTheDocument()
expect(containerDiv.children.length).toBe(0)
})
it('should apply group-hover opacity classes', () => {
render(<More more={mockMoreData} />)
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(<More more={dataWithLargeTokens} />)
// formatNumber(1234567) should return '1,234,567'
expect(screen.getByText(/1,234,567/)).toBeInTheDocument()
})
})

View File

@@ -13,19 +13,24 @@ const More: FC<MoreProps> = ({
const { t } = useTranslation()
return (
<div className="system-xs-regular mt-1 flex items-center text-text-quaternary opacity-0 group-hover:opacity-100">
<div
className="mt-1 flex items-center text-text-quaternary opacity-0 system-xs-regular group-hover:opacity-100"
data-testid="more-container"
>
{
more && (
<>
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
data-testid="more-latency"
>
{`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
</div>
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
data-testid="more-tokens"
>
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
</div>
@@ -33,6 +38,7 @@ const More: FC<MoreProps> = ({
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${more.tokens_per_second} tokens/s`}
data-testid="more-tps"
>
{`${more.tokens_per_second} tokens/s`}
</div>
@@ -41,6 +47,7 @@ const More: FC<MoreProps> = ({
<div
className="max-w-[25%] shrink-0 truncate"
title={more.time}
data-testid="more-time"
>
{more.time}
</div>

View File

@@ -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
? (
<div data-testid="edit-reply-modal">
<button data-testid="modal-hide" onClick={onHide}>Close</button>
<button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}>Edit</button>
<button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}>Add</button>
<button data-testid="modal-remove" onClick={onRemove}>Remove</button>
</div>
)
: 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 (
<div data-testid="annotation-ctrl">
{cached
? (
<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>
)
: (
<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>
)}
</div>
)
},
}))
vi.mock('@/app/components/base/new-audio-button', () => ({
default: () => <button data-testid="audio-btn">Play</button>,
}))
vi.mock('@/app/components/base/chat/chat/log', () => ({
default: () => <button data-testid="log-btn"><div className="i-ri-file-list-3-line" /></button>,
}))
vi.mock('next/navigation', () => ({
useParams: vi.fn(() => ({ appId: 'test-app' })),
usePathname: vi.fn(() => '/apps/test-app'),
}))
const makeChatConfig = (overrides: Partial<ChatConfig> = {}): 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(
<div className="group">
<Operation {...props} />
</div>,
)
}
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')
})
})
})

View File

@@ -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<OperationProps> = ({
}
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<OperationProps> = ({
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
data-testid="operation-bar"
>
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
@@ -204,8 +189,8 @@ const Operation: FC<OperationProps> = ({
onClick={() => handleFeedback(null, undefined, 'user')}
>
{displayUserFeedback?.rating === 'like'
? <RiThumbUpLine className="h-4 w-4" />
: <RiThumbDownLine className="h-4 w-4" />}
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
)
@@ -215,13 +200,13 @@ const Operation: FC<OperationProps> = ({
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<RiThumbUpLine className="h-4 w-4" />
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
<ActionButton
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('user')}
>
<RiThumbDownLine className="h-4 w-4" />
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</>
)}
@@ -242,12 +227,12 @@ const Operation: FC<OperationProps> = ({
{displayUserFeedback.rating === 'like'
? (
<ActionButton state={ActionButtonState.Active}>
<RiThumbUpLine className="h-4 w-4" />
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
)
: (
<ActionButton state={ActionButtonState.Destructive}>
<RiThumbDownLine className="h-4 w-4" />
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
)}
</Tooltip>
@@ -266,8 +251,8 @@ const Operation: FC<OperationProps> = ({
onClick={() => handleFeedback(null, undefined, 'admin')}
>
{adminLocalFeedback?.rating === 'like'
? <RiThumbUpLine className="h-4 w-4" />
: <RiThumbDownLine className="h-4 w-4" />}
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
)
@@ -281,7 +266,7 @@ const Operation: FC<OperationProps> = ({
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<RiThumbUpLine className="h-4 w-4" />
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip
@@ -292,7 +277,7 @@ const Operation: FC<OperationProps> = ({
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('admin')}
>
<RiThumbDownLine className="h-4 w-4" />
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</Tooltip>
</>
@@ -305,7 +290,7 @@ const Operation: FC<OperationProps> = ({
</div>
)}
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" data-testid="operation-actions">
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
@@ -314,17 +299,19 @@ const Operation: FC<OperationProps> = ({
/>
)}
{!humanInputFormDataList?.length && (
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
<ActionButton
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
data-testid="copy-btn"
>
<RiClipboardLine className="h-4 w-4" />
<div className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className="h-4 w-4" />
<ActionButton onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
<div className="i-ri-reset-left-line h-4 w-4" />
</ActionButton>
)}
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
@@ -366,7 +353,7 @@ const Operation: FC<OperationProps> = ({
>
<div className="space-y-3">
<div>
<label className="system-sm-semibold mb-2 block text-text-secondary">
<label className="mb-2 block text-text-secondary system-sm-semibold">
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea

View File

@@ -0,0 +1,83 @@
import type { Mock } from 'vitest' // Or 'jest' if using Jest
import type { IChatItem } from '../type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useChatContext } from '../context'
import SuggestedQuestions from './suggested-questions'
// Mock the chat context
vi.mock('../context', () => ({
useChatContext: vi.fn(),
}))
describe('SuggestedQuestions', () => {
const mockOnSend = vi.fn()
beforeEach(() => {
vi.clearAllMocks();
// Use 'as Mock' instead of 'as any'
(useChatContext as Mock).mockReturnValue({
onSend: mockOnSend,
readonly: false,
})
})
const mockItem: IChatItem = {
id: '1',
content: '',
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: ['What is Dify?', 'How to use it?', ' ', ''],
}
it('should render suggested questions and filter out empty ones', () => {
render(<SuggestedQuestions item={mockItem} />)
const questions = screen.getAllByTestId('suggested-question')
expect(questions).toHaveLength(2)
expect(questions[0]).toHaveTextContent('What is Dify?')
expect(questions[1]).toHaveTextContent('How to use it?')
})
it('should call onSend when a question is clicked', async () => {
const user = userEvent.setup()
render(<SuggestedQuestions item={mockItem} />)
const questions = screen.getAllByTestId('suggested-question')
await user.click(questions[0])
expect(mockOnSend).toHaveBeenCalledWith('What is Dify?')
})
it('should not render if isOpeningStatement is false', () => {
render(<SuggestedQuestions item={{ ...mockItem, isOpeningStatement: false }} />)
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
})
it('should not render if suggestedQuestions is missing or empty', () => {
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: [] }} />)
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
// Use 'as IChatItem' instead of 'as any'
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: undefined } as IChatItem} />)
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
})
it('should be disabled and not call onSend when readonly is true', async () => {
const user = userEvent.setup();
// Use 'as Mock' instead of 'as any'
(useChatContext as Mock).mockReturnValue({
onSend: mockOnSend,
readonly: true,
})
render(<SuggestedQuestions item={mockItem} />)
const questions = screen.getAllByTestId('suggested-question')
expect(questions[0]).toHaveClass('pointer-events-none')
expect(questions[0]).toHaveClass('opacity-50')
await user.click(questions[0])
expect(mockOnSend).not.toHaveBeenCalled()
})
})

View File

@@ -26,10 +26,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
<div
key={index}
className={cn(
'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
'mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs system-sm-medium last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
readonly && 'pointer-events-none opacity-50',
)}
onClick={() => !readonly && onSend?.(question)}
data-testid="suggested-question"
>
{question}
</div>

View File

@@ -0,0 +1,74 @@
import type { ToolInfoInThought } from '../type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ToolDetail from './tool-detail'
describe('ToolDetail', () => {
const mockPayload: ToolInfoInThought = {
name: 'test_tool',
label: 'Test Tool Label',
input: 'test input content',
output: 'test output content',
isFinished: true,
}
const datasetPayload: ToolInfoInThought = {
...mockPayload,
name: 'dataset_123',
label: 'Dataset Label',
}
it('should render the tool label and "used" state when finished', () => {
render(<ToolDetail payload={mockPayload} />)
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
expect(screen.getByText('tools.thought.used')).toBeInTheDocument()
})
it('should render the knowledge label and "using" state when not finished and name is a dataset', () => {
render(<ToolDetail payload={{ ...datasetPayload, isFinished: false }} />)
expect(screen.getByText('dataset.knowledge')).toBeInTheDocument()
expect(screen.getByText('tools.thought.using')).toBeInTheDocument()
})
it('should toggle expansion and show request/response details on click', async () => {
const user = userEvent.setup()
render(<ToolDetail payload={mockPayload} />)
// Initially collapsed: request/response titles should not be visible
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
expect(screen.queryByText(mockPayload.input)).not.toBeInTheDocument()
// Click to expand
const label = screen.getByText('Test Tool Label')
await user.click(label)
// Now expanded
expect(screen.getByText('tools.thought.requestTitle')).toBeInTheDocument()
expect(screen.getByText(mockPayload.input)).toBeInTheDocument()
expect(screen.getByText('tools.thought.responseTitle')).toBeInTheDocument()
expect(screen.getByText(mockPayload.output)).toBeInTheDocument()
// Click again to collapse
await user.click(label)
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
})
it('should apply different styles when expanded', async () => {
const user = userEvent.setup()
const { container } = render(<ToolDetail payload={mockPayload} />)
const rootDiv = container.firstChild as HTMLElement
const label = screen.getByText('Test Tool Label')
const headerDiv = label.parentElement!
// Initial styles
expect(rootDiv).toHaveClass('bg-workflow-process-bg')
expect(headerDiv).not.toHaveClass('pb-1.5')
// Expand
await user.click(label)
expect(rootDiv).toHaveClass('bg-background-section-burn')
expect(headerDiv).toHaveClass('pb-1.5')
})
})

View File

@@ -0,0 +1,109 @@
import type { WorkflowProcess } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import WorkflowProcessItem from './workflow-process'
// Mock TracingPanel as it's a complex child component
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: () => <div data-testid="tracing-panel">Tracing Panel</div>,
}))
describe('WorkflowProcessItem', () => {
const mockData = {
status: WorkflowRunningStatus.Succeeded,
tracing: [
{ id: '1', title: 'Start' },
{ id: '2', title: 'End' },
],
}
it('should render the latest node title when collapsed', () => {
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
})
it('should render "Workflow Process" title and TracingPanel when expanded', () => {
// We expect t('common.workflowProcess', { ns: 'workflow' }) to be called
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={true} />)
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
})
it('should toggle collapse state on header click', async () => {
const user = userEvent.setup()
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
const header = screen.getByTestId('workflow-process-header')
// Expand
await user.click(header)
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
// Collapse
await user.click(header)
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
})
it('should render nothing if readonly is true', () => {
const { container } = render(<WorkflowProcessItem data={mockData as WorkflowProcess} readonly={true} />)
expect(container.firstChild).toBeNull()
})
describe('Status Icons', () => {
it('should show running spinner when status is Running', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-running')).toBeInTheDocument()
})
it('should show success circle when status is Succeeded', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-success')).toBeInTheDocument()
})
it('should show error warning when status is Failed', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
})
it('should show error warning when status is Stopped', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Stopped } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
})
it('should show pause circle when status is Paused', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-paused')).toBeInTheDocument()
})
})
describe('Background Colors', () => {
it('should apply correct background when collapsed for different statuses', () => {
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-bg')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-failed-bg')
})
it('should apply correct background when expanded for different statuses', () => {
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-background-section-burn')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-success-hover')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-destructive-hover')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-warning-hover')
})
})
})

View File

@@ -1,16 +1,10 @@
import type { ChatItem, WorkflowProcess } from '../../types'
import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
import {
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
@@ -58,35 +52,52 @@ const WorkflowProcessItem = ({
collapse && paused && 'bg-workflow-process-paused-bg',
collapse && failed && 'bg-workflow-process-failed-bg',
)}
data-testid="workflow-process-item"
>
<div
className={cn('flex cursor-pointer items-center', !collapse && 'px-1.5')}
onClick={() => setCollapse(!collapse)}
data-testid="workflow-process-header"
>
{
running && (
<RiLoader2Line className="mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary" />
<div
className="i-ri-loader-2-line mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary"
data-testid="status-icon-running"
/>
)
}
{
succeeded && (
<CheckCircle className="mr-1 h-3.5 w-3.5 shrink-0 text-text-success" />
<div
className="i-custom-vender-solid-general-check-circle mr-1 h-3.5 w-3.5 shrink-0 text-text-success"
data-testid="status-icon-success"
/>
)
}
{
failed && (
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
<div
className="i-ri-error-warning-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive"
data-testid="status-icon-failed"
/>
)
}
{
paused && (
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
<div
className="i-ri-pause-circle-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary"
data-testid="status-icon-paused"
/>
)
}
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
<div
className={cn('text-text-secondary system-xs-medium', !collapse && 'grow')}
data-testid="workflow-process-title"
>
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
</div>
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
<div className={cn('i-ri-arrow-right-s-line ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
</div>
{
!collapse && (

View File

@@ -0,0 +1,568 @@
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { TransferMethod } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { vi } from 'vitest'
import ChatInputArea from './index'
// ---------------------------------------------------------------------------
// Hoist shared mock references so they are available inside vi.mock factories
// ---------------------------------------------------------------------------
const { mockGetPermission, mockNotify } = vi.hoisted(() => ({
mockGetPermission: vi.fn().mockResolvedValue(undefined),
mockNotify: vi.fn(),
}))
// ---------------------------------------------------------------------------
// External dependency mocks
// ---------------------------------------------------------------------------
vi.mock('js-audio-recorder', () => ({
default: class {
static getPermission = mockGetPermission
start = vi.fn()
stop = vi.fn()
getWAVBlob = vi.fn().mockReturnValue(new Blob([''], { type: 'audio/wav' }))
getRecordAnalyseData = vi.fn().mockReturnValue(new Uint8Array(128))
},
}))
vi.mock('@/service/share', () => ({
audioToText: vi.fn().mockResolvedValue({ text: 'Converted text' }),
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
}))
// ---------------------------------------------------------------------------
// File-uploader store shared mutable state so individual tests can mutate it
// ---------------------------------------------------------------------------
const mockFileStore: { files: FileEntity[], setFiles: ReturnType<typeof vi.fn> } = {
files: [],
setFiles: vi.fn(),
}
vi.mock('@/app/components/base/file-uploader/store', () => ({
useFileStore: () => ({ getState: () => mockFileStore }),
useStore: (selector: (s: typeof mockFileStore) => unknown) => selector(mockFileStore),
FileContextProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}))
// ---------------------------------------------------------------------------
// File-uploader hooks provide stable drag/drop handlers
// ---------------------------------------------------------------------------
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFile: () => ({
handleDragFileEnter: vi.fn(),
handleDragFileLeave: vi.fn(),
handleDragFileOver: vi.fn(),
handleDropFile: vi.fn(),
handleClipboardPasteFile: vi.fn(),
isDragActive: false,
}),
}))
// ---------------------------------------------------------------------------
// Features context hook avoids needing FeaturesContext.Provider in the tree
// ---------------------------------------------------------------------------
// FeatureBar calls: useFeatures(s => s.features)
// So the selector receives the store state object; we must nest the features
// under a `features` key to match what the real store exposes.
const mockFeaturesState = {
features: {
moreLikeThis: { enabled: false },
opening: { enabled: false },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false },
file: { enabled: false },
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
},
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (s: typeof mockFeaturesState) => unknown) =>
selector(mockFeaturesState),
}))
// ---------------------------------------------------------------------------
// Toast context
// ---------------------------------------------------------------------------
vi.mock('@/app/components/base/toast', async () => {
const actual = await vi.importActual<typeof import('@/app/components/base/toast')>(
'@/app/components/base/toast',
)
return {
...actual,
useToastContext: () => ({ notify: mockNotify }),
}
})
// ---------------------------------------------------------------------------
// Internal layout hook controls single/multi-line textarea mode
// ---------------------------------------------------------------------------
let mockIsMultipleLine = false
vi.mock('./hooks', () => ({
useTextAreaHeight: () => ({
wrapperRef: { current: document.createElement('div') },
textareaRef: { current: document.createElement('textarea') },
textValueRef: { current: document.createElement('div') },
holdSpaceRef: { current: document.createElement('div') },
handleTextareaResize: vi.fn(),
get isMultipleLine() {
return mockIsMultipleLine
},
}),
}))
// ---------------------------------------------------------------------------
// Input-forms validation hook always passes by default
// ---------------------------------------------------------------------------
vi.mock('../check-input-forms-hooks', () => ({
useCheckInputsForms: () => ({
checkInputsForm: vi.fn().mockReturnValue(true),
}),
}))
// ---------------------------------------------------------------------------
// Next.js navigation
// ---------------------------------------------------------------------------
vi.mock('next/navigation', () => ({
useParams: () => ({ token: 'test-token' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/test',
}))
// ---------------------------------------------------------------------------
// Shared fixture typed as FileUpload to avoid implicit any
// ---------------------------------------------------------------------------
// const mockVisionConfig: FileUpload = {
// fileUploadConfig: {
// image_file_size_limit: 10,
// file_size_limit: 10,
// audio_file_size_limit: 10,
// video_file_size_limit: 10,
// workflow_file_upload_limit: 10,
// },
// allowed_file_types: [],
// allowed_file_extensions: [],
// enabled: true,
// number_limits: 3,
// transfer_methods: ['local_file', 'remote_url'],
// } as FileUpload
const mockVisionConfig: FileUpload = {
// Required because of '& EnabledOrDisabled' at the end of your type
enabled: true,
// The nested config object
fileUploadConfig: {
image_file_size_limit: 10,
file_size_limit: 10,
audio_file_size_limit: 10,
video_file_size_limit: 10,
workflow_file_upload_limit: 10,
batch_count_limit: 0,
image_file_batch_limit: 0,
single_chunk_attachment_limit: 0,
attachment_image_file_size_limit: 0,
file_upload_limit: 0,
},
// These match the keys in your FileUpload type
allowed_file_types: [],
allowed_file_extensions: [],
number_limits: 3,
// NOTE: Your type defines 'allowed_file_upload_methods',
// not 'transfer_methods' at the top level.
allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[],
// If you wanted to define specific image/video behavior:
image: {
enabled: true,
number_limits: 3,
transfer_methods: ['local_file', 'remote_url'] as TransferMethod[],
},
}
// ---------------------------------------------------------------------------
// Minimal valid FileEntity fixture avoids undefined `type` crash in FileItem
// ---------------------------------------------------------------------------
const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'photo.png',
type: 'image/png', // required: FileItem calls type.split('/')[0]
size: 1024,
progress: 100,
transferMethod: 'local_file',
uploadedId: 'uploaded-ok',
...overrides,
} as FileEntity)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const getTextarea = () => screen.getByPlaceholderText(/inputPlaceholder/i)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChatInputArea', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFileStore.files = []
mockIsMultipleLine = false
})
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render the textarea with default placeholder', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(getTextarea()).toBeInTheDocument()
})
it('should render the readonly placeholder when readonly prop is set', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} readonly />)
expect(screen.getByPlaceholderText(/inputDisabledPlaceholder/i)).toBeInTheDocument()
})
it('should render the send button', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
it('should apply disabled styles when the disabled prop is true', () => {
const { container } = render(<ChatInputArea visionConfig={mockVisionConfig} disabled />)
const disabledWrapper = container.querySelector('.pointer-events-none')
expect(disabledWrapper).toBeInTheDocument()
})
it('should render the operation section inline when single-line', () => {
// mockIsMultipleLine is false by default
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
it('should render the operation section below the textarea when multi-line', () => {
mockIsMultipleLine = true
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
describe('Typing', () => {
it('should update textarea value as the user types', async () => {
const user = userEvent.setup()
render(<ChatInputArea visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world')
expect(getTextarea()).toHaveValue('Hello world')
})
it('should clear the textarea after a message is successfully sent', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world')
await user.click(screen.getByTestId('send-button'))
expect(getTextarea()).toHaveValue('')
})
})
// -------------------------------------------------------------------------
describe('Sending Messages', () => {
it('should call onSend with query and files when clicking the send button', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world')
await user.click(screen.getByTestId('send-button'))
expect(onSend).toHaveBeenCalledTimes(1)
expect(onSend).toHaveBeenCalledWith('Hello world', [])
})
it('should call onSend and reset the input when pressing Enter', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world{Enter}')
expect(onSend).toHaveBeenCalledWith('Hello world', [])
expect(getTextarea()).toHaveValue('')
})
it('should NOT call onSend when pressing Shift+Enter (inserts newline instead)', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world{Shift>}{Enter}{/Shift}')
expect(onSend).not.toHaveBeenCalled()
expect(getTextarea()).toHaveValue('Hello world\n')
})
it('should NOT call onSend in readonly mode', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} readonly />)
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
})
it('should pass already-uploaded files to onSend', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
// makeFile ensures `type` is always a proper MIME string
const uploadedFile = makeFile({ id: 'file-1', name: 'photo.png', uploadedId: 'uploaded-123' })
mockFileStore.files = [uploadedFile]
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'With attachment')
await user.click(screen.getByTestId('send-button'))
expect(onSend).toHaveBeenCalledWith('With attachment', [uploadedFile])
})
})
// -------------------------------------------------------------------------
describe('History Navigation', () => {
it('should restore the last sent message when pressing Cmd+ArrowUp once', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, 'Second{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
expect(textarea).toHaveValue('Second')
})
it('should go further back in history with repeated Cmd+ArrowUp', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, 'Second{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
expect(textarea).toHaveValue('First')
})
it('should move forward in history when pressing Cmd+ArrowDown', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, 'Second{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Second
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → Second
expect(textarea).toHaveValue('Second')
})
it('should clear the input when navigating past the most recent history entry', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → past end → ''
expect(textarea).toHaveValue('')
})
it('should not go below the start of history when pressing Cmd+ArrowUp at the boundary', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'Only{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Only
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → '' (seed at index 0)
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // boundary should stay at ''
expect(textarea).toHaveValue('')
})
})
// -------------------------------------------------------------------------
describe('Voice Input', () => {
it('should render the voice input button when speech-to-text is enabled', () => {
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('voice-input-button')).toBeInTheDocument()
})
it('should NOT render the voice input button when speech-to-text is disabled', () => {
render(<ChatInputArea speechToTextConfig={{ enabled: false }} visionConfig={mockVisionConfig} />)
expect(screen.queryByTestId('voice-input-button')).not.toBeInTheDocument()
})
it('should request microphone permission when the voice button is clicked', async () => {
const user = userEvent.setup()
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
await user.click(screen.getByTestId('voice-input-button'))
expect(mockGetPermission).toHaveBeenCalledTimes(1)
})
it('should notify with an error when microphone permission is denied', async () => {
const user = userEvent.setup()
mockGetPermission.mockRejectedValueOnce(new Error('Permission denied'))
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
await user.click(screen.getByTestId('voice-input-button'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
})
it('should NOT invoke onSend while voice input is being activated', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(
<ChatInputArea
onSend={onSend}
speechToTextConfig={{ enabled: true }}
visionConfig={mockVisionConfig}
/>,
)
await user.click(screen.getByTestId('voice-input-button'))
expect(onSend).not.toHaveBeenCalled()
})
})
// -------------------------------------------------------------------------
describe('Validation', () => {
it('should notify and NOT call onSend when the query is blank', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should notify and NOT call onSend when the query contains only whitespace', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), ' ')
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should notify and NOT call onSend while the bot is already responding', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} isResponding visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello')
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should notify and NOT call onSend while a file upload is still in progress', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
// uploadedId is empty string → upload not yet finished
mockFileStore.files = [
makeFile({ id: 'file-upload', uploadedId: '', progress: 50 }),
]
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello')
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should call onSend normally when all uploaded files have completed', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
// uploadedId is present → upload finished
mockFileStore.files = [makeFile({ uploadedId: 'uploaded-ok' })]
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'With completed file')
await user.click(screen.getByTestId('send-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
describe('Feature Bar', () => {
it('should render the FeatureBar section when showFeatureBar is true', () => {
const { container } = render(
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar />,
)
// FeatureBar renders a rounded-bottom container beneath the input
expect(container.querySelector('[class*="rounded-b"]')).toBeInTheDocument()
})
it('should NOT render the FeatureBar when showFeatureBar is false', () => {
const { container } = render(
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar={false} />,
)
expect(container.querySelector('[class*="rounded-b"]')).not.toBeInTheDocument()
})
it('should not invoke onFeatureBarClick when the component is in readonly mode', async () => {
const user = userEvent.setup()
const onFeatureBarClick = vi.fn()
render(
<ChatInputArea
visionConfig={mockVisionConfig}
showFeatureBar
readonly
onFeatureBarClick={onFeatureBarClick}
/>,
)
// In readonly mode the FeatureBar receives `noop` as its click handler.
// Click every button that is not a named test-id button to exercise the guard.
const buttons = screen.queryAllByRole('button')
for (const btn of buttons) {
if (!btn.dataset.testid)
await user.click(btn)
}
expect(onFeatureBarClick).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,170 @@
import type { EnableType } from '../../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Theme } from '../../embedded-chatbot/theme/theme-context'
import Operation from './operation'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInChatInput: ({ readonly }: { readonly?: boolean }) => (
<div data-testid="file-uploader" data-readonly={readonly} />
),
}))
const createMockTheme = (overrides?: Partial<Theme>): Theme => {
const theme = new Theme()
theme.primaryColor = 'rgb(255, 0, 0)'
return Object.assign(theme, overrides || {})
}
describe('Operation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render send button always', () => {
render(<Operation onSend={vi.fn()} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render file uploader when fileConfig.enabled is true', () => {
const fileConfig: FileUpload = { enabled: true } as FileUpload
render(
<Operation
onSend={vi.fn()}
fileConfig={fileConfig}
/>,
)
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
})
it('should not render file uploader when fileConfig is undefined', () => {
render(<Operation onSend={vi.fn()} />)
expect(screen.queryByTestId('file-uploader')).not.toBeInTheDocument()
})
it('should render voice input button when speechToTextConfig.enabled is true', () => {
const speechConfig: EnableType = { enabled: true }
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={speechConfig}
/>,
)
expect(screen.getAllByRole('button')).toHaveLength(2)
})
it('should not render voice input button when speechToTextConfig.enabled is false', () => {
const speechConfig: EnableType = { enabled: false }
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={speechConfig}
/>,
)
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
describe('Send Button Behavior', () => {
it('should call onSend when clicked and not readonly', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<Operation onSend={onSend} />)
await user.click(screen.getByRole('button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
it('should not call onSend when readonly is true', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<Operation onSend={onSend} readonly />)
await user.click(screen.getByRole('button'))
expect(onSend).not.toHaveBeenCalled()
})
it('should apply theme primaryColor as background style when theme is provided', () => {
render(
<Operation
onSend={vi.fn()}
theme={createMockTheme()}
/>,
)
expect(screen.getByRole('button')).toHaveStyle({
backgroundColor: 'rgb(255, 0, 0)',
})
})
it('should not apply background style when theme is null', () => {
render(
<Operation
onSend={vi.fn()}
theme={null}
/>,
)
expect(screen.getByRole('button').style.backgroundColor).toBe('')
})
})
describe('Voice Input Button', () => {
it('should call onShowVoiceInput when clicked', async () => {
const user = userEvent.setup()
const onShowVoiceInput = vi.fn()
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={{ enabled: true }}
onShowVoiceInput={onShowVoiceInput}
/>,
)
const buttons = screen.getAllByRole('button')
const voiceButton = buttons[0]
await user.click(voiceButton)
expect(onShowVoiceInput).toHaveBeenCalledTimes(1)
})
it('should disable voice button when readonly is true', async () => {
const user = userEvent.setup()
const onShowVoiceInput = vi.fn()
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={{ enabled: true }}
onShowVoiceInput={onShowVoiceInput}
readonly
/>,
)
const buttons = screen.getAllByRole('button')
const voiceButton = buttons[0]
expect(voiceButton).toBeDisabled()
await user.click(voiceButton)
expect(onShowVoiceInput).not.toHaveBeenCalled()
})
})
})

View File

@@ -51,6 +51,7 @@ const Operation: FC<OperationProps> = ({
size="l"
disabled={readonly}
onClick={onShowVoiceInput}
data-testid="voice-input-button"
>
<RiMicLine className="h-5 w-5" />
</ActionButton>
@@ -61,6 +62,7 @@ const Operation: FC<OperationProps> = ({
className="ml-3 w-8 px-0"
variant="primary"
onClick={readonly ? noop : onSend}
data-testid="send-button"
style={
theme
? {

View File

@@ -0,0 +1,364 @@
import type { CitationItem } from '../type'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import Citation from './index'
vi.mock('./popup', () => ({
default: ({ data, showHitInfo }: { data: { documentName: string }, showHitInfo?: boolean }) => (
<div data-testid="popup" data-show-hit-info={String(!!showHitInfo)}>
{data.documentName}
</div>
),
}))
const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
type ClientWidthConfig = {
container: number
item: number
}
const mockClientWidths = ({ container, item }: ClientWidthConfig) => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
get() {
const el = this as HTMLElement
if (el.className?.includes?.('chat-answer-container') || el.className?.includes?.('my-custom-container'))
return container
if (el.dataset?.testid === 'citation-measurement-item')
return item
return 0
},
configurable: true,
})
}
const restoreClientWidth = () => {
if (originalClientWidthDescriptor)
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidthDescriptor)
}
afterAll(() => {
restoreClientWidth()
})
const makeCitationItem = (overrides: Partial<CitationItem> = {}): CitationItem => ({
document_id: 'doc-1',
document_name: 'Document One',
data_source_type: 'upload_file',
segment_id: 'seg-1',
content: 'Some content',
dataset_id: 'dataset-1',
dataset_name: 'Dataset One',
segment_position: 1,
word_count: 100,
hit_count: 5,
index_node_hash: 'abc123',
score: 0.95,
...overrides,
})
const setupContainer = (className = 'chat-answer-container') => {
const wrapper = document.createElement('div')
wrapper.className = className
document.body.appendChild(wrapper)
return wrapper
}
describe('Citation', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
restoreClientWidth()
})
describe('Rendering', () => {
it('should render the citation title section', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
})
it('should render one measurement ghost item per unique document', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Alpha' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Beta' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
})
it('should display the document name inside each measurement item', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem({ document_name: 'My Report' })]} />)
expect(screen.getByTestId('citation-measurement-item')).toHaveTextContent('My Report')
})
it('should render a popup for each resource that fits within the container', () => {
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1' }),
makeCitationItem({ document_id: 'doc-2' }),
]}
/>,
)
expect(screen.getAllByTestId('popup')).toHaveLength(2)
})
it('should render the citation title i18n key', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getByText(/citation\.title/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should use chat-answer-container as the default containerClassName', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
})
it('should use a custom containerClassName to resolve the container element', () => {
mockClientWidths({ container: 600, item: 50 })
setupContainer('my-custom-container')
render(<Citation data={[makeCitationItem()]} containerClassName="my-custom-container" />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
})
it('should forward showHitInfo=true to each rendered Popup', () => {
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(
<Citation
data={[
makeCitationItem({ document_id: 'doc-1' }),
makeCitationItem({ document_id: 'doc-2' }),
]}
showHitInfo={true}
/>,
)
screen.getAllByTestId('popup').forEach(p =>
expect(p).toHaveAttribute('data-show-hit-info', 'true'),
)
})
it('should forward showHitInfo=false when prop is omitted', () => {
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem({ document_id: 'doc-1' })]} />)
screen.getAllByTestId('popup').forEach(p =>
expect(p).toHaveAttribute('data-show-hit-info', 'false'),
)
})
})
describe('Resource Grouping', () => {
it('should merge citations with the same document_id into one resource', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'shared', segment_id: 'seg-1' }),
makeCitationItem({ document_id: 'shared', segment_id: 'seg-2' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
})
it('should create a separate resource for each distinct document_id', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-a' }),
makeCitationItem({ document_id: 'doc-b' }),
makeCitationItem({ document_id: 'doc-c' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(3)
})
it('should handle mixed shared and unique document_ids correctly', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-1' }),
makeCitationItem({ document_id: 'doc-y', segment_id: 'seg-2' }),
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-3' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
})
})
describe('Layout Adjustment all resources fit', () => {
it('should show all popups and no more-toggle when every item fits within container', () => {
// effective containerWidth = 840 - 40 = 800; each item = 50px → all 3 fit
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1' }),
makeCitationItem({ document_id: 'doc-2' }),
makeCitationItem({ document_id: 'doc-3' }),
]}
/>,
)
expect(screen.getAllByTestId('popup')).toHaveLength(3)
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
})
})
describe('Layout Adjustment overflow branch: setLimitNumberInOneLine(i - 1)', () => {
it('should show more-toggle when backed-out totalWidth + 34 still exceeds containerWidth', () => {
// effective = 140 - 40 = 100
// i=0: total=80, 80+0=80 ≤ 100 → limit=1
// i=1: total=160, 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
// 0 < 2 → toggle shown
mockClientWidths({ container: 140, item: 80 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
]}
/>,
)
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
})
})
describe('Layout Adjustment overflow branch: setLimitNumberInOneLine(i)', () => {
it('should show more-toggle and limit=i when backed-out totalWidth + 34 fits within containerWidth', () => {
// effective = 240 - 40 = 200
// i=0: 80+0=80 ≤ 200 → limit=1
// i=1: 160+4=164 ≤ 200 → limit=2
// i=2: 240+8=248 > 200 → overflow; back-out=160; 160+34=194 ≤ 200 → setLimit(2)
// 2 < 3 → toggle shown; 2 popups visible
mockClientWidths({ container: 240, item: 80 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
]}
/>,
)
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
expect(screen.getAllByTestId('popup')).toHaveLength(2)
})
})
describe('Show More / Show Less Toggle', () => {
const renderOverflowScenario = () => {
// effective = 140 - 40 = 100; items=80px
// i=0: 80 ≤ 100 → limit=1
// i=1: 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
// 0 < 3 → toggle shown; 0 popups visible (slice(0, 0) = [])
mockClientWidths({ container: 140, item: 80 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
]}
/>,
)
return screen.getByTestId('citation-more-toggle')
}
it('should show the overflow count label matching /+\\s*\\d+/ on the more-toggle in collapsed state', () => {
renderOverflowScenario()
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
})
it('should display the collapse icon div after clicking more-toggle to expand', async () => {
const user = userEvent.setup()
renderOverflowScenario()
await user.click(screen.getByTestId('citation-more-toggle'))
expect(document.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
it('should return to the count label after clicking the toggle a second time to collapse', async () => {
const user = userEvent.setup()
renderOverflowScenario()
await user.click(screen.getByTestId('citation-more-toggle'))
await user.click(screen.getByTestId('citation-more-toggle'))
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
})
it('should show all resource popups after expanding via the more-toggle', async () => {
const user = userEvent.setup()
renderOverflowScenario()
await user.click(screen.getByTestId('citation-more-toggle'))
await waitFor(() => {
expect(screen.getAllByTestId('popup')).toHaveLength(3)
})
})
})
describe('Edge Cases', () => {
it('should render without crashing when data is an empty array', () => {
mockClientWidths({ container: 500, item: 0 })
setupContainer()
render(<Citation data={[]} />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
expect(screen.queryAllByTestId('citation-measurement-item')).toHaveLength(0)
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
})
it('should render correctly with a single citation item that fits', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
})
it('should handle all citations sharing one document_id as a single resource', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'only', segment_id: 's1' }),
makeCitationItem({ document_id: 'only', segment_id: 's2' }),
makeCitationItem({ document_id: 'only', segment_id: 's3' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
})
it('should handle a large number of citation items without throwing', () => {
mockClientWidths({ container: 5000, item: 50 })
setupContainer()
const data = Array.from({ length: 20 }, (_, i) =>
makeCitationItem({ document_id: `doc-${i}`, document_name: `Document ${i}` }))
expect(() => render(<Citation data={data} />)).not.toThrow()
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(20)
})
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { CitationItem } from '../type'
import { RiArrowDownSLine } from '@remixicon/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Popup from './popup'
@@ -47,37 +46,36 @@ const Citation: FC<CitationProps> = ({
return prev
}, []), [data])
const handleAdjustResourcesLayout = () => {
useEffect(() => {
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
let totalWidth = 0
let limit = 0
for (let i = 0; i < resources.length; i++) {
totalWidth += elesRef.current[i].clientWidth
if (totalWidth + i * 4 > containerWidth!) {
if (totalWidth + i * 4 > containerWidth) {
totalWidth -= elesRef.current[i].clientWidth
if (totalWidth + 34 > containerWidth!)
setLimitNumberInOneLine(i - 1)
if (totalWidth + 34 > containerWidth)
limit = i - 1
else
setLimitNumberInOneLine(i)
limit = i
break
}
else {
setLimitNumberInOneLine(i + 1)
limit = i + 1
}
}
}
useEffect(() => {
handleAdjustResourcesLayout()
setLimitNumberInOneLine(limit)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const resourcesLength = resources.length
return (
<div className="-mb-1 mt-3">
<div className="system-xs-medium mb-2 flex items-center text-text-tertiary">
<div data-testid="citation-title" className="mb-2 flex items-center text-text-tertiary system-xs-medium">
{t('chat.citation.title', { ns: 'common' })}
<div className="ml-2 h-px grow bg-divider-regular" />
</div>
@@ -85,17 +83,18 @@ const Citation: FC<CitationProps> = ({
{
resources.map((res, index) => (
<div
key={index}
key={res.documentId}
data-testid="citation-measurement-item"
className="absolute left-0 top-0 -z-10 mb-1 mr-1 h-7 w-auto max-w-[240px] whitespace-nowrap pl-7 pr-2 text-xs opacity-0"
ref={(ele: any) => (elesRef.current[index] = ele!)}
ref={(ele: HTMLDivElement | null) => { elesRef.current[index] = ele! }}
>
{res.documentName}
</div>
))
}
{
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
<div key={index} className="mb-1 mr-1 cursor-pointer">
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map(res => (
<div key={res.documentId} className="mb-1 mr-1 cursor-pointer">
<Popup
data={res}
showHitInfo={showHitInfo}
@@ -106,13 +105,14 @@ const Citation: FC<CitationProps> = ({
{
limitNumberInOneLine < resourcesLength && (
<div
className="system-xs-medium flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary"
data-testid="citation-more-toggle"
className="flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary system-xs-medium"
onClick={() => setShowMore(v => !v)}
>
{
!showMore
? `+ ${resourcesLength - limitNumberInOneLine}`
: <RiArrowDownSLine className="h-4 w-4 rotate-180 text-text-tertiary" />
: <div className="i-ri-arrow-down-s-line h-4 w-4 rotate-180 text-text-tertiary" />
}
</div>
)

View File

@@ -0,0 +1,609 @@
import type { Resources } from './index'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import Popup from './popup'
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDownload: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
vi.mock('@/app/components/base/file-icon', () => ({
default: ({ type }: { type: string }) => <div data-testid="file-icon" data-type={type} />,
}))
vi.mock('./progress-tooltip', () => ({
default: ({ data }: { data: number }) => <div data-testid="progress-tooltip">{data}</div>,
}))
vi.mock('./tooltip', () => ({
default: ({ text, data }: { text: string, data: number | string }) => (
<div data-testid="citation-tooltip" data-text={text}>{data}</div>
),
}))
const mockDownloadDocument = vi.fn()
const mockUseDocumentDownload = vi.mocked(useDocumentDownload)
const mockDownloadUrl = vi.mocked(downloadUrl)
const makeSource = (overrides: Partial<Resources['sources'][number]> = {}): Resources['sources'][number] => ({
dataset_id: 'ds-1',
dataset_name: 'Test Dataset',
document_id: 'doc-1',
segment_id: 'seg-1',
segment_position: 1,
content: 'Source content here',
word_count: 120,
hit_count: 3,
index_node_hash: 'abcdef1234567',
score: 0.85,
data_source_type: 'upload_file',
document_name: 'test.pdf',
...overrides,
} as Resources['sources'][number])
const makeData = (overrides: Partial<Resources> = {}): Resources => ({
documentId: 'doc-1',
documentName: 'report.pdf',
dataSourceType: 'upload_file',
sources: [makeSource()],
...overrides,
})
const openPopup = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId('popup-trigger'))
}
describe('Popup', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseDocumentDownload.mockReturnValue({
mutateAsync: mockDownloadDocument,
isPending: false,
} as unknown as ReturnType<typeof useDocumentDownload>)
})
describe('Rendering Trigger', () => {
it('should render the trigger element', () => {
render(<Popup data={makeData()} />)
expect(screen.getByTestId('popup-trigger')).toBeInTheDocument()
})
it('should show the document name in the trigger', () => {
render(<Popup data={makeData({ documentName: 'My Report.pdf' })} />)
expect(screen.getByTestId('popup-trigger')).toHaveTextContent('My Report.pdf')
})
it('should pass the extracted file extension to FileIcon for non-notion sources', () => {
render(<Popup data={makeData({ documentName: 'report.pdf', dataSourceType: 'upload_file' })} />)
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'pdf')
})
it('should pass notion as fileType to FileIcon for notion sources', () => {
render(<Popup data={makeData({ documentName: 'Notion Page', dataSourceType: 'notion' })} />)
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'notion')
})
it('should pass empty string as fileType when document has no extension', () => {
render(<Popup data={makeData({ documentName: 'nodotfile', dataSourceType: 'upload_file' })} />)
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', '')
})
it('should not render popup content before trigger is clicked', () => {
render(<Popup data={makeData()} />)
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
})
})
describe('Popup Open / Close', () => {
it('should open the popup content on trigger click', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
})
it('should close the popup on second trigger click', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
await openPopup(user)
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
})
it('should re-open popup after open → close → open cycle', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
await openPopup(user)
await openPopup(user)
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
})
})
describe('Popup Header Download Button', () => {
it('should render download button in header for upload_file dataSourceType with dataset_id', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
})
it('should render download button in header for file dataSourceType with dataset_id', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'file',
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
})
it('should render plain document name in header (no button) for notion type', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
documentName: 'Notion Doc',
dataSourceType: 'notion',
sources: [makeSource({ dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
})
it('should render plain document name in header when dataset_id is absent', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'upload_file',
sources: [makeSource({ dataset_id: '' })],
})}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
})
it('should disable the download button while isDownloading is true', async () => {
mockUseDocumentDownload.mockReturnValue({
mutateAsync: mockDownloadDocument,
isPending: true,
} as unknown as ReturnType<typeof useDocumentDownload>)
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
expect(screen.getByTestId('popup-download-btn')).toBeDisabled()
})
})
describe('Source Items', () => {
it('should render one source item per source entry', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource(), makeSource({ segment_id: 'seg-2' })] })} />)
await openPopup(user)
expect(screen.getAllByTestId('popup-source-item')).toHaveLength(2)
})
it('should render source content text', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ content: 'Unique content text' })] })} />)
await openPopup(user)
expect(screen.getByTestId('popup-source-content')).toHaveTextContent('Unique content text')
})
it('should show segment_position when it is truthy', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 7 })] })} />)
await openPopup(user)
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('7')
})
it('should fall back to index + 1 when segment_position is 0', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 0 })] })} />)
await openPopup(user)
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('1')
})
})
describe('Source Dividers', () => {
it('should render a divider between multiple sources', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
sources: [makeSource(), makeSource({ segment_id: 'seg-2' }), makeSource({ segment_id: 'seg-3' })],
})}
/>,
)
await openPopup(user)
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(2)
})
it('should not render any divider for a single source', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource()] })} />)
await openPopup(user)
expect(screen.queryByTestId('popup-source-divider')).not.toBeInTheDocument()
})
it('should render exactly n-1 dividers for n sources', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
sources: [
makeSource({ segment_id: 's1' }),
makeSource({ segment_id: 's2' }),
makeSource({ segment_id: 's3' }),
makeSource({ segment_id: 's4' }),
],
})}
/>,
)
await openPopup(user)
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(3)
})
})
describe('showHitInfo=false (default)', () => {
it('should not render the dataset link when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryByTestId('popup-dataset-link')).not.toBeInTheDocument()
})
it('should not render hit info section when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryByTestId('popup-hit-info')).not.toBeInTheDocument()
})
it('should not render Tooltip components when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryAllByTestId('citation-tooltip')).toHaveLength(0)
})
it('should not render ProgressTooltip when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
})
})
describe('showHitInfo=true', () => {
const dataWithScore = makeData({ sources: [makeSource({ score: 0.85 })] })
it('should render the dataset link when showHitInfo is true', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-dataset-link')).toBeInTheDocument()
})
it('should render the dataset link with correct href', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-dataset-link')).toHaveAttribute(
'href',
`/datasets/${dataWithScore.sources[0].dataset_id}/documents/${dataWithScore.sources[0].document_id}`,
)
})
it('should render the linkToDataset i18n key in the link', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-dataset-link')).toHaveTextContent(/linkToDataset/i)
})
it('should render hit info section when showHitInfo is true', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-hit-info')).toBeInTheDocument()
})
it('should render three Tooltip components (characters, hitCount, vectorHash)', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getAllByTestId('citation-tooltip')).toHaveLength(3)
})
it('should render ProgressTooltip when source score is greater than 0', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ score: 0.9 })] })} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('progress-tooltip')).toBeInTheDocument()
})
it('should not render ProgressTooltip when source score is 0', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ score: 0 })] })} showHitInfo={true} />)
await openPopup(user)
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
})
it('should pass score rounded to 2 decimal places to ProgressTooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ score: 0.856 })] })} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('progress-tooltip')).toHaveTextContent('0.86')
})
it('should pass word_count to the characters Tooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ word_count: 250 })] })} showHitInfo={true} />)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[0]).toHaveTextContent('250')
})
it('should pass hit_count to the hitCount Tooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ hit_count: 7 })] })} showHitInfo={true} />)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[1]).toHaveTextContent('7')
})
it('should pass truncated index_node_hash (first 7 chars) to vectorHash Tooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ index_node_hash: 'abcdef1234567' })] })} showHitInfo={true} />)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[2]).toHaveTextContent('abcdef1')
})
it('should render hit info for each source when multiple sources are present', async () => {
const user = userEvent.setup()
render(
<Popup
data={makeData({
sources: [makeSource({ score: 0.9 }), makeSource({ segment_id: 'seg-2', score: 0.7 })],
})}
showHitInfo={true}
/>,
)
await openPopup(user)
expect(screen.getAllByTestId('popup-hit-info')).toHaveLength(2)
})
})
describe('handleDownloadUploadFile', () => {
it('should call downloadDocument and downloadUrl on successful download', async () => {
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => {
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'doc-1' })
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/file.pdf', fileName: 'report.pdf' })
})
})
it('should not call downloadUrl when res.url is absent', async () => {
mockDownloadDocument.mockResolvedValue({ url: '' })
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => expect(mockDownloadDocument).toHaveBeenCalled())
expect(mockDownloadUrl).not.toHaveBeenCalled()
})
it('should not call downloadDocument when dataSourceType is not upload_file or file', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'notion',
sources: [makeSource({ dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
expect(mockDownloadDocument).not.toHaveBeenCalled()
})
it('should not call downloadDocument when isDownloading is true', async () => {
mockUseDocumentDownload.mockReturnValue({
mutateAsync: mockDownloadDocument,
isPending: true,
} as unknown as ReturnType<typeof useDocumentDownload>)
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
expect(mockDownloadDocument).not.toHaveBeenCalled()
})
it('should use documentId from data.documentId as priority over sources[0].document_id', async () => {
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
const user = userEvent.setup()
render(
<Popup data={makeData({
documentId: 'primary-doc-id',
dataSourceType: 'upload_file',
sources: [makeSource({ document_id: 'fallback-doc-id', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => {
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'primary-doc-id' })
})
})
it('should work with file dataSourceType the same as upload_file', async () => {
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'file',
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => {
expect(mockDownloadDocument).toHaveBeenCalled()
expect(mockDownloadUrl).toHaveBeenCalled()
})
})
it('should not call downloadDocument when both data.documentId and sources[0].document_id are empty', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
documentId: '',
dataSourceType: 'upload_file',
sources: [makeSource({ document_id: '', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
expect(mockDownloadDocument).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should render without crashing with minimum required props', () => {
expect(() => render(<Popup data={makeData()} />)).not.toThrow()
})
it('should render without crashing with an empty sources array', () => {
expect(() => render(<Popup data={makeData({ sources: [] })} />)).not.toThrow()
})
it('should render correctly when source has no score (undefined)', async () => {
const user = userEvent.setup()
render(
<Popup
data={makeData({
sources: [makeSource({ score: undefined })],
})}
showHitInfo={true}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
})
it('should render correctly when index_node_hash is undefined', async () => {
const user = userEvent.setup()
render(
<Popup
data={makeData({
sources: [makeSource({ index_node_hash: undefined })],
})}
showHitInfo={true}
/>,
)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[2]).toBeInTheDocument()
})
})
})

View File

@@ -4,15 +4,6 @@ import Link from 'next/link'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileIcon from '@/app/components/base/file-icon'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import {
BezierCurve03,
TypeSquare,
} from '@/app/components/base/icons/src/vender/line/editor'
import {
Hash02,
Target04,
} from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -40,23 +31,16 @@ const Popup: FC<PopupProps> = ({
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
/**
* Download the original uploaded file for citations whose data source is upload-file.
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
*/
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
// Prevent toggling the citation popup when user clicks the download link.
e.preventDefault()
e.stopPropagation()
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
const datasetId = data.sources?.[0]?.dataset_id
const documentId = data.documentId || data.sources?.[0]?.document_id
if (!isUploadFile || !datasetId || !documentId || isDownloading)
return
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
const res = await downloadDocument({ datasetId, documentId })
if (res?.url)
downloadUrl({ url: res.url, fileName: data.documentName })
@@ -73,22 +57,21 @@ const Popup: FC<PopupProps> = ({
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
{/* Keep the trigger purely for opening the popup (no download link here). */}
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
<div className="px-4 pb-2 pt-3">
<div className="flex h-[18px] items-center">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="system-xs-medium truncate text-text-tertiary">
{/* If it's an upload-file reference, the title becomes a download link. */}
<div className="truncate text-text-tertiary system-xs-medium">
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
<button
data-testid="popup-download-btn"
type="button"
className="cursor-pointer truncate text-text-tertiary hover:underline"
onClick={handleDownloadUploadFile}
@@ -104,63 +87,71 @@ const Popup: FC<PopupProps> = ({
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
<div className="w-full">
{
data.sources.map((source, index) => (
<Fragment key={index}>
<div className="group py-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
<Hash02 className="mr-0.5 h-3 w-3 text-text-quaternary" />
<div className="text-[11px] font-medium text-text-tertiary">
{source.segment_position || index + 1}
data.sources.map((source, index) => {
const itemKey = source.document_id
? `${source.document_id}-${source.segment_position ?? index}`
: source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}`
return (
<Fragment key={itemKey}>
<div data-testid="popup-source-item" className="group py-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
{/* replaced svg component with tailwind icon class per lint rule */}
<i className="i-custom-vender-line-general-hash-02 mr-0.5 h-3 w-3 text-text-quaternary" aria-hidden />
<div data-testid="popup-segment-position" className="text-[11px] font-medium text-text-tertiary">
{source.segment_position || index + 1}
</div>
</div>
{
showHitInfo && (
<Link
data-testid="popup-dataset-link"
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
>
{t('chat.citation.linkToDataset', { ns: 'common' })}
<i className="i-custom-vender-line-arrows-arrow-up-right ml-1 h-3 w-3" aria-hidden />
</Link>
)
}
</div>
<div data-testid="popup-source-content" className="break-words text-[13px] text-text-secondary">{source.content}</div>
{
showHitInfo && (
<Link
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
>
{t('chat.citation.linkToDataset', { ns: 'common' })}
<ArrowUpRight className="ml-1 h-3 w-3" />
</Link>
<div data-testid="popup-hit-info" className="mt-2 flex flex-wrap items-center text-text-quaternary system-xs-medium">
<Tooltip
text={t('chat.citation.characters', { ns: 'common' })}
data={source.word_count}
icon={<i className="i-custom-vender-line-editor-type-square mr-1 h-3 w-3" aria-hidden />}
/>
<Tooltip
text={t('chat.citation.hitCount', { ns: 'common' })}
data={source.hit_count}
icon={<i className="i-custom-vender-line-general-target-04 mr-1 h-3 w-3" aria-hidden />}
/>
<Tooltip
text={t('chat.citation.vectorHash', { ns: 'common' })}
data={source.index_node_hash?.substring(0, 7)}
icon={<i className="i-custom-vender-line-editor-bezier-curve-03 mr-1 h-3 w-3" aria-hidden />}
/>
{
!!source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}
</div>
)
}
</div>
<div className="break-words text-[13px] text-text-secondary">{source.content}</div>
{
showHitInfo && (
<div className="system-xs-medium mt-2 flex flex-wrap items-center text-text-quaternary">
<Tooltip
text={t('chat.citation.characters', { ns: 'common' })}
data={source.word_count}
icon={<TypeSquare className="mr-1 h-3 w-3" />}
/>
<Tooltip
text={t('chat.citation.hitCount', { ns: 'common' })}
data={source.hit_count}
icon={<Target04 className="mr-1 h-3 w-3" />}
/>
<Tooltip
text={t('chat.citation.vectorHash', { ns: 'common' })}
data={source.index_node_hash?.substring(0, 7)}
icon={<BezierCurve03 className="mr-1 h-3 w-3" />}
/>
{
!!source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}
</div>
index !== data.sources.length - 1 && (
<div data-testid="popup-source-divider" className="my-1 h-px bg-divider-regular" />
)
}
</div>
{
index !== data.sources.length - 1 && (
<div className="my-1 h-px bg-divider-regular" />
)
}
</Fragment>
))
</Fragment>
)
})
}
</div>
</div>

View File

@@ -0,0 +1,144 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ProgressTooltip from './progress-tooltip'
describe('ProgressTooltip', () => {
describe('Rendering', () => {
it('should render the trigger content', () => {
render(<ProgressTooltip data={0.75} />)
expect(screen.getByTestId('progress-trigger-content')).toBeInTheDocument()
})
it('should render the data value in the trigger', () => {
render(<ProgressTooltip data={0.75} />)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.75')
})
it('should render the progress bar fill element', () => {
render(<ProgressTooltip data={0.5} />)
expect(screen.getByTestId('progress-bar-fill')).toBeInTheDocument()
})
it('should not render the tooltip popup before hovering', () => {
render(<ProgressTooltip data={0.5} />)
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
})
})
describe('Progress Bar Width', () => {
it('should set fill width to data * 100 percent', () => {
render(<ProgressTooltip data={0.75} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '75%' })
})
it('should set fill width to 0% when data is 0', () => {
render(<ProgressTooltip data={0} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '0%' })
})
it('should set fill width to 100% when data is 1', () => {
render(<ProgressTooltip data={1} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '100%' })
})
it('should set fill width to 50% when data is 0.5', () => {
render(<ProgressTooltip data={0.5} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '50%' })
})
})
describe('Tooltip Visibility', () => {
it('should show the tooltip popup on mouse enter', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
})
it('should hide the tooltip popup on mouse leave', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
await user.unhover(screen.getByTestId('progress-trigger-content'))
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
})
it('should show the hitScore i18n key in the tooltip', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i)
})
it('should show the data value inside the tooltip popup', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent('0.8')
})
})
describe('Props', () => {
it('should render correctly with a small fractional value', () => {
render(<ProgressTooltip data={0.12} />)
expect(screen.getByTestId('progress-bar-fill').getAttribute('style')).toMatch(/width:\s*12/)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.12')
})
it('should render correctly with a value close to 1', () => {
render(<ProgressTooltip data={0.99} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '99%' })
})
it('should update displayed data when prop changes', () => {
const { rerender } = render(<ProgressTooltip data={0.3} />)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.3')
rerender(<ProgressTooltip data={0.9} />)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.9')
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '90%' })
})
})
describe('Edge Cases', () => {
it('should render without crashing when data is exactly 0', () => {
expect(() => render(<ProgressTooltip data={0} />)).not.toThrow()
})
it('should render without crashing when data is exactly 1', () => {
expect(() => render(<ProgressTooltip data={1} />)).not.toThrow()
})
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.5} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
await user.unhover(screen.getByTestId('progress-trigger-content'))
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
})
it('should keep tooltip closed without any interaction', () => {
render(<ProgressTooltip data={0.42} />)
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
})
it('should not call any external handlers by default', () => {
const consoleError = vi.spyOn(console, 'error')
render(<ProgressTooltip data={0.5} />)
expect(consoleError).not.toHaveBeenCalled()
consoleError.mockRestore()
})
})
})

View File

@@ -27,15 +27,20 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className="flex grow items-center">
<div data-testid="progress-trigger-content" className="flex grow items-center">
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
<div className="h-full bg-components-progress-gray-progress" style={{ width: `${data * 100}%` }}></div>
<div
data-testid="progress-bar-fill"
className="h-full bg-components-progress-gray-progress"
style={{ width: `${data * 100}%` }}
>
</div>
</div>
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
<div data-testid="progress-tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
{t('chat.citation.hitScore', { ns: 'common' })}
{' '}
{data}

View File

@@ -0,0 +1,155 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import Tooltip from './tooltip'
const renderTooltip = (data: number | string = 42, text = 'Characters', icon = <span data-testid="mock-icon">icon</span>) =>
render(<Tooltip data={data} text={text} icon={icon} />)
describe('Tooltip', () => {
describe('Rendering', () => {
it('should render the trigger content wrapper', () => {
renderTooltip()
expect(screen.getByTestId('tooltip-trigger-content')).toBeInTheDocument()
})
it('should render the icon inside the trigger', () => {
renderTooltip(42, 'Characters', <span data-testid="mock-icon">icon</span>)
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
})
it('should render a numeric data value in the trigger', () => {
renderTooltip(123)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('123')
})
it('should render a string data value in the trigger', () => {
renderTooltip('abc123')
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('abc123')
})
it('should not render the tooltip popup before hovering', () => {
renderTooltip()
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should render the provided text label when tooltip is open', async () => {
const user = userEvent.setup()
renderTooltip(10, 'Word Count')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Word Count')
})
it('should render the data value inside the tooltip popup', async () => {
const user = userEvent.setup()
renderTooltip(99, 'Hit Count')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('99')
})
it('should render a string data value inside the tooltip popup', async () => {
const user = userEvent.setup()
renderTooltip('abc1234', 'Vector Hash')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('abc1234')
})
it('should render both text and data together inside the tooltip popup', async () => {
const user = userEvent.setup()
renderTooltip(55, 'Characters')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
const popup = screen.getByTestId('tooltip-popup')
expect(popup).toHaveTextContent('Characters')
expect(popup).toHaveTextContent('55')
})
it('should render any arbitrary ReactNode as icon', () => {
render(<Tooltip data={1} text="text" icon={<div data-testid="custom-icon"></div>} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should update displayed data when prop changes', () => {
const { rerender } = render(<Tooltip data={10} text="Words" icon={<span />} />)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('10')
rerender(<Tooltip data={20} text="Words" icon={<span />} />)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('20')
})
it('should update displayed text in popup when prop changes and tooltip is open', async () => {
const user = userEvent.setup()
const { rerender } = render(<Tooltip data={10} text="Original" icon={<span />} />)
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Original')
rerender(<Tooltip data={10} text="Updated" icon={<span />} />)
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Updated')
})
})
describe('Tooltip Visibility', () => {
it('should show the tooltip popup on mouse enter', async () => {
const user = userEvent.setup()
renderTooltip()
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
})
it('should hide the tooltip popup on mouse leave', async () => {
const user = userEvent.setup()
renderTooltip()
await user.hover(screen.getByTestId('tooltip-trigger-content'))
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
})
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
const user = userEvent.setup()
renderTooltip()
await user.hover(screen.getByTestId('tooltip-trigger-content'))
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render without crashing when data is 0', () => {
expect(() => render(<Tooltip data={0} text="score" icon={<span />} />)).not.toThrow()
})
it('should render without crashing when data is an empty string', () => {
expect(() => render(<Tooltip data="" text="label" icon={<span />} />)).not.toThrow()
})
it('should render without crashing when text is an empty string', () => {
expect(() => render(<Tooltip data={1} text="" icon={<span />} />)).not.toThrow()
})
it('should keep tooltip closed without any interaction', () => {
renderTooltip(0.5)
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
})
it('should render data value 0 in the trigger', () => {
render(<Tooltip data={0} text="score" icon={<span />} />)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('0')
})
})
})

View File

@@ -30,13 +30,13 @@ const Tooltip: FC<TooltipProps> = ({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className="mr-6 flex items-center">
<div data-testid="tooltip-trigger-content" className="mr-6 flex items-center">
{icon}
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
<div data-testid="tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
{text}
{' '}
{data}

View File

@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ContentSwitch from './content-switch'
describe('ContentSwitch', () => {
const defaultProps = {
count: 3,
currentIndex: 1,
prevDisabled: false,
nextDisabled: false,
switchSibling: vi.fn(),
}
it('renders nothing when count is 1 or less', () => {
const { container } = render(<ContentSwitch {...defaultProps} count={1} />)
expect(container.firstChild).toBeNull()
})
it('renders nothing when currentIndex is undefined', () => {
const { container } = render(<ContentSwitch {...defaultProps} currentIndex={undefined} />)
expect(container.firstChild).toBeNull()
})
it('renders correctly with current page and total count', () => {
render(<ContentSwitch {...defaultProps} currentIndex={0} count={5} />)
expect(screen.getByText(/1[^\n\r/\u2028\u2029]*\/.*5/)).toBeInTheDocument()
})
it('calls switchSibling with "prev" when left button is clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
const prevButton = screen.getByRole('button', { name: /previous/i })
await user.click(prevButton)
expect(switchSibling).toHaveBeenCalledWith('prev')
})
it('calls switchSibling with "next" when right button is clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
const nextButton = screen.getByRole('button', { name: /next/i })
await user.click(nextButton)
expect(switchSibling).toHaveBeenCalledWith('next')
})
it('applies disabled styles and prevents clicks when prevDisabled is true', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} prevDisabled={true} switchSibling={switchSibling} />)
const prevButton = screen.getByRole('button', { name: /previous/i })
expect(prevButton).toHaveClass('opacity-30')
expect(prevButton).toBeDisabled()
await user.click(prevButton)
expect(switchSibling).not.toHaveBeenCalled()
})
it('applies disabled styles and prevents clicks when nextDisabled is true', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} nextDisabled={true} switchSibling={switchSibling} />)
const nextButton = screen.getByRole('button', { name: /next/i })
expect(nextButton).toHaveClass('opacity-30')
expect(nextButton).toBeDisabled()
await user.click(nextButton)
expect(switchSibling).not.toHaveBeenCalled()
})
})

View File

@@ -18,6 +18,7 @@ export default function ContentSwitch({
<div className="flex items-center justify-center pt-3.5 text-sm">
<button
type="button"
aria-label="Previous" // Added for accessibility and testing
className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`}
disabled={prevDisabled}
onClick={() => !prevDisabled && switchSibling('prev')}
@@ -32,6 +33,7 @@ export default function ContentSwitch({
</span>
<button
type="button"
aria-label="Next" // Added for accessibility and testing
className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`}
disabled={nextDisabled}
onClick={() => !nextDisabled && switchSibling('next')}

View File

@@ -0,0 +1,94 @@
import type { ChatItem } from '../types'
import type { ChatContextValue } from './context'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ChatContextProvider, useChatContext } from './context'
const TestConsumer = () => {
const context = useChatContext()
return (
<div>
<div data-testid="isResponding">{String(context.isResponding)}</div>
<div data-testid="readonly">{String(context.readonly)}</div>
<div data-testid="chatListCount">{context.chatList.length}</div>
<div data-testid="questionIcon">{context.questionIcon}</div>
<button onClick={() => context.onSend?.('test message', [])}>Send Button</button>
<button onClick={() => context.onRegenerate?.({ id: '1' } as ChatItem, { message: 'retry' })}>Regenerate Button</button>
</div>
)
}
describe('ChatContextProvider', () => {
const mockOnSend = vi.fn()
const mockOnRegenerate = vi.fn()
const defaultProps: ChatContextValue = {
config: {} as ChatContextValue['config'],
isResponding: false,
chatList: [{ id: '1', content: 'hello' } as ChatItem],
showPromptLog: false,
questionIcon: <span data-testid="custom-icon">Icon</span>,
answerIcon: null,
onSend: mockOnSend,
onRegenerate: mockOnRegenerate,
onAnnotationEdited: vi.fn(),
onAnnotationAdded: vi.fn(),
onAnnotationRemoved: vi.fn(),
disableFeedback: false,
onFeedback: vi.fn(),
getHumanInputNodeData: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should provide context values to children', () => {
render(
<ChatContextProvider {...defaultProps} readonly={true}>
<TestConsumer />
</ChatContextProvider>,
)
expect(screen.getByTestId('isResponding')).toHaveTextContent('false')
expect(screen.getByTestId('readonly')).toHaveTextContent('true')
expect(screen.getByTestId('chatListCount')).toHaveTextContent('1')
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should use default values for optional props', () => {
const minimalProps = { ...defaultProps, chatList: undefined as unknown as ChatItem[] }
render(
<ChatContextProvider {...minimalProps}>
<TestConsumer />
</ChatContextProvider>,
)
expect(screen.getByTestId('chatListCount')).toHaveTextContent('0')
expect(screen.getByTestId('readonly')).toHaveTextContent('false')
})
it('should handle callbacks correctly via the hook', async () => {
const user = userEvent.setup()
render(
<ChatContextProvider {...defaultProps}>
<TestConsumer />
</ChatContextProvider>,
)
const sendBtn = screen.getByRole('button', { name: /send button/i })
const regenBtn = screen.getByRole('button', { name: /regenerate button/i })
await user.click(sendBtn)
expect(mockOnSend).toHaveBeenCalledWith('test message', [])
await user.click(regenBtn)
expect(mockOnRegenerate).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.objectContaining({ message: 'retry' }),
)
})
})

View File

@@ -0,0 +1,606 @@
import type { ChatConfig, ChatItem, OnSend } from '../types'
import type { ChatProps } from './index'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from './index'
// ─── Why each mock exists ─────────────────────────────────────────────────────
//
// Answer transitively pulls Markdown (rehype/remark/katex), AgentContent,
// WorkflowProcessItem and Operation; none can resolve in jsdom.
// Question pulls Markdown, copy-to-clipboard, react-textarea-autosize.
// ChatInputArea pulls js-audio-recorder (requires Web Audio API unavailable in
// jsdom) and VoiceInput / FileContextProvider chains.
// PromptLogModal pulls CopyFeedbackNew and deep modal dep chain.
// AgentLogModal pulls @remixicon/react (causes lint push error), useClickAway
// from ahooks, and AgentLogDetail (workflow graph renderer).
// es-toolkit/compat debounce must return a fn with .cancel() or the cleanup
// effect throws on unmount.
//
// NOT mocked (run real):
// ChatContextProvider plain context wrapper, zero side-effects.
// TryToAsk only uses Button (base), Divider (base), i18n (global mock).
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('./answer', () => ({
default: ({ item, responding }: { item: ChatItem, responding?: boolean }) => (
<div
data-testid="answer-item"
data-id={item.id}
data-responding={String(!!responding)}
>
{item.content}
</div>
),
}))
vi.mock('./question', () => ({
default: ({ item }: { item: ChatItem }) => (
<div data-testid="question-item" data-id={item.id}>{item.content}</div>
),
}))
vi.mock('./chat-input-area', () => ({
default: ({ disabled, readonly }: { disabled?: boolean, readonly?: boolean }) => (
<div
data-testid="chat-input-area"
data-disabled={String(!!disabled)}
data-readonly={String(!!readonly)}
/>
),
}))
vi.mock('@/app/components/base/prompt-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="prompt-log-modal">
<button data-testid="prompt-log-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="agent-log-modal">
<button data-testid="agent-log-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: (...args: unknown[]) => void) => {
const debounced = (...args: unknown[]) => fn(...args)
debounced.cancel = vi.fn()
return debounced
},
}))
// ─── ResizeObserver capture ───────────────────────────────────────────────────
type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
let capturedResizeCallbacks: ResizeCallback[] = []
const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({
borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
contentRect: new DOMRect(0, 0, inlineSize, blockSize),
devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
target: document.createElement('div'),
})
// ─── Factories ────────────────────────────────────────────────────────────────
const makeChatItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
id: `item-${Math.random().toString(36).slice(2)}`,
content: 'Test content',
isAnswer: false,
...overrides,
})
const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn()
const mockSetShowAgentLogModal = vi.fn()
const baseStoreState = {
currentLogItem: undefined,
setCurrentLogItem: mockSetCurrentLogItem,
showPromptLogModal: false,
setShowPromptLogModal: mockSetShowPromptLogModal,
showAgentLogModal: false,
setShowAgentLogModal: mockSetShowAgentLogModal,
}
const renderChat = (props: Partial<ChatProps> = {}) =>
render(<Chat chatList={[]} {...props} />)
// ─── Suite ────────────────────────────────────────────────────────────────────
describe('Chat', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedResizeCallbacks = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
vi.stubGlobal('ResizeObserver', class {
private cb: ResizeCallback
constructor(cb: ResizeCallback) {
this.cb = cb
capturedResizeCallbacks.push(cb)
}
observe() { }
unobserve() { }
disconnect() { }
})
useAppStore.setState(baseStoreState)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render without crashing with an empty chatList', () => {
renderChat()
expect(screen.getByTestId('chat-root')).toBeInTheDocument()
})
it('should render chatNode when provided', () => {
renderChat({ chatNode: <div data-testid="slot-node">slot</div> })
expect(screen.getByTestId('slot-node')).toBeInTheDocument()
})
it('should apply flex-col to root when isTryApp=true', () => {
renderChat({ isTryApp: true })
expect(screen.getByTestId('chat-root')).toHaveClass('flex', 'flex-col')
})
it('should not have flex-col when isTryApp is falsy', () => {
renderChat({ isTryApp: false })
expect(screen.getByTestId('chat-root')).not.toHaveClass('flex-col')
})
it('should apply chatContainerClassName to the scroll container', () => {
renderChat({ chatContainerClassName: 'my-custom-class' })
expect(screen.getByTestId('chat-container')).toHaveClass('my-custom-class')
})
it('should apply px-8 spacing by default', () => {
const { container } = renderChat({ noSpacing: false })
expect(container.querySelector('.w-full')).toHaveClass('px-8')
})
it('should omit px-8 when noSpacing=true', () => {
const { container } = renderChat({ noSpacing: true })
expect(container.querySelector('.w-full')).not.toHaveClass('px-8')
})
})
describe('Chat List', () => {
it('should render a Question for a non-answer item', () => {
renderChat({ chatList: [makeChatItem({ id: 'q1', isAnswer: false })] })
expect(screen.getByTestId('question-item')).toBeInTheDocument()
})
it('should render an Answer for an answer item', () => {
renderChat({ chatList: [makeChatItem({ id: 'a1', isAnswer: true })] })
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
})
it('should render both Question and Answer from a mixed chatList', () => {
renderChat({
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
],
})
expect(screen.getByTestId('question-item')).toBeInTheDocument()
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
})
it('should pass responding=true only to the last answer when isResponding=true', () => {
renderChat({
isResponding: true,
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
makeChatItem({ id: 'q2', isAnswer: false }),
makeChatItem({ id: 'a2', isAnswer: true }),
],
})
const answers = screen.getAllByTestId('answer-item')
expect(answers[0]).toHaveAttribute('data-responding', 'false')
expect(answers[1]).toHaveAttribute('data-responding', 'true')
})
it('should pass responding=false to all answers when isResponding=false', () => {
renderChat({
isResponding: false,
chatList: [
makeChatItem({ id: 'a1', isAnswer: true }),
makeChatItem({ id: 'a2', isAnswer: true }),
],
})
screen.getAllByTestId('answer-item').forEach(el =>
expect(el).toHaveAttribute('data-responding', 'false'),
)
})
it('should render correct counts for a long mixed chatList', () => {
const chatList = Array.from({ length: 6 }, (_, i) =>
makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 }))
renderChat({ chatList })
expect(screen.getAllByTestId('question-item')).toHaveLength(3)
expect(screen.getAllByTestId('answer-item')).toHaveLength(3)
})
})
describe('Stop Responding Button', () => {
it('should show the stop button when isResponding=true and noStopResponding is falsy', () => {
renderChat({ isResponding: true, noStopResponding: false })
expect(screen.getByTestId('stop-responding-container')).toBeInTheDocument()
})
it('should hide the stop button when noStopResponding=true', () => {
renderChat({ isResponding: true, noStopResponding: true })
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
})
it('should hide the stop button when isResponding=false', () => {
renderChat({ isResponding: false, noStopResponding: false })
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
})
it('should call onStopResponding when the stop button is clicked', async () => {
const user = userEvent.setup()
const onStopResponding = vi.fn()
renderChat({ isResponding: true, noStopResponding: false, onStopResponding })
await user.click(screen.getByText(/stopResponding/i))
expect(onStopResponding).toHaveBeenCalledTimes(1)
})
it('should render the stopResponding i18n key', () => {
renderChat({ isResponding: true, noStopResponding: false })
expect(screen.getByText(/stopResponding/i)).toBeInTheDocument()
})
})
describe('TryToAsk (real component)', () => {
const tryToAskConfig: ChatConfig = {
suggested_questions_after_answer: { enabled: true },
} as ChatConfig
const mockOnSend = vi.fn() as unknown as OnSend
it('should render the tryToAsk i18n key when all conditions are met', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['What is AI?'],
onSend: mockOnSend,
})
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
})
it('should render each suggested question as a button', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['First question', 'Second question'],
onSend: mockOnSend,
})
expect(screen.getByText('First question')).toBeInTheDocument()
expect(screen.getByText('Second question')).toBeInTheDocument()
})
it('should call onSend with the question text when a suggestion button is clicked', async () => {
const user = userEvent.setup()
const onSend = vi.fn() as unknown as OnSend
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['Ask this'],
onSend,
})
await user.click(screen.getByText('Ask this'))
expect(onSend).toHaveBeenCalledWith('Ask this')
})
it('should not render TryToAsk when suggested_questions_after_answer is disabled', () => {
renderChat({
config: { suggested_questions_after_answer: { enabled: false } } as ChatConfig,
suggestedQuestions: ['q1'],
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when suggestedQuestions is an empty array', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: [],
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when suggestedQuestions is undefined', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: undefined,
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when onSend is undefined', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['q1'],
onSend: undefined,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when config is undefined', () => {
renderChat({
config: undefined,
suggestedQuestions: ['q1'],
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
})
describe('ChatInputArea', () => {
it('should render when noChatInput is falsy', () => {
renderChat({ noChatInput: false })
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
})
it('should not render when noChatInput=true', () => {
renderChat({ noChatInput: true })
expect(screen.queryByTestId('chat-input-area')).not.toBeInTheDocument()
})
it('should pass disabled=true when inputDisabled=true', () => {
renderChat({ inputDisabled: true })
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'true')
})
it('should pass disabled=false when inputDisabled is falsy', () => {
renderChat({ inputDisabled: false })
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'false')
})
it('should pass readonly=true to ChatInputArea when readonly=true', () => {
renderChat({ readonly: true })
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-readonly', 'true')
})
})
describe('PromptLogModal', () => {
it('should render when showPromptLogModal=true and hideLogModal is falsy', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
})
it('should not render when showPromptLogModal=false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false })
renderChat()
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
})
it('should not render when hideLogModal=true even if showPromptLogModal=true', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
})
it('should call setCurrentLogItem and setShowPromptLogModal(false) on cancel', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('prompt-log-cancel'))
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
})
})
describe('AgentLogModal', () => {
it('should render when showAgentLogModal=true and hideLogModal is falsy', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
it('should not render when showAgentLogModal=false', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: false })
renderChat()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should not render when hideLogModal=true even if showAgentLogModal=true', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should call setCurrentLogItem and setShowAgentLogModal(false) on cancel', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('agent-log-cancel'))
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
})
})
describe('Window Resize', () => {
it('should register a resize listener on mount', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
renderChat()
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function))
})
it('should remove the resize listener on unmount', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const { unmount } = renderChat()
unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
})
it('should not throw when the resize event fires', () => {
renderChat()
expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow()
})
})
describe('ResizeObserver Callbacks', () => {
it('should set paddingBottom on chatContainer from the footer blockSize', () => {
renderChat({
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
],
})
const containerCb = capturedResizeCallbacks[0]
if (containerCb) {
act(() => containerCb([makeResizeEntry(80, 400)], {} as ResizeObserver))
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
}
})
it('should set footer width from the container inlineSize', () => {
renderChat()
const footerCb = capturedResizeCallbacks[1]
if (footerCb) {
act(() => footerCb([makeResizeEntry(50, 600)], {} as ResizeObserver))
expect(screen.getByTestId('chat-footer').style.width).toBe('600px')
}
})
it('should disconnect both observers on unmount', () => {
const disconnectSpy = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe() { }
unobserve() { }
disconnect = disconnectSpy
})
const { unmount } = renderChat()
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
})
describe('Scroll Behavior', () => {
it('should not throw when chatList has 1 item (scroll guard: length > 1 not met)', () => {
expect(() => renderChat({ chatList: [makeChatItem({ id: 'q1' })] })).not.toThrow()
})
it('should not throw when a scroll event fires on the container', () => {
renderChat()
expect(() =>
screen.getByTestId('chat-container').dispatchEvent(new Event('scroll')),
).not.toThrow()
})
it('should set userScrolled when distanceToBottom exceeds threshold', () => {
renderChat()
const container = screen.getByTestId('chat-container')
Object.defineProperty(container, 'scrollHeight', { value: 1000, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(container, 'scrollTop', { value: 0, configurable: true })
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
})
it('should not set userScrolled when distanceToBottom is within threshold', () => {
renderChat()
const container = screen.getByTestId('chat-container')
Object.defineProperty(container, 'scrollHeight', { value: 500, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(container, 'scrollTop', { value: 99, configurable: true })
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
})
})
describe('ChatList Scroll Reset', () => {
it('should not throw with empty chatList (length <= 1 branch)', () => {
expect(() => renderChat({ chatList: [] })).not.toThrow()
})
it('should not throw with exactly one item (length <= 1 branch)', () => {
expect(() => renderChat({ chatList: [makeChatItem({ id: 'msg-1' })] })).not.toThrow()
})
it('should reset scroll state when the first message ID changes on rerender', () => {
const { rerender } = renderChat({
chatList: [makeChatItem({ id: 'first' }), makeChatItem({ id: 'second' })],
})
expect(() =>
rerender(<Chat chatList={[makeChatItem({ id: 'new-first' }), makeChatItem({ id: 'new-second' })]} />),
).not.toThrow()
})
it('should not reset scroll when the first message ID is unchanged', () => {
const item1 = makeChatItem({ id: 'stable-id' })
const { rerender } = renderChat({ chatList: [item1, makeChatItem({ id: 'second' })] })
expect(() =>
rerender(<Chat chatList={[item1, makeChatItem({ id: 'third' })]} />),
).not.toThrow()
})
})
describe('Sidebar Collapse State', () => {
it('should schedule a resize via setTimeout when sidebarCollapseState becomes false', () => {
vi.useFakeTimers()
const { rerender } = renderChat({ sidebarCollapseState: true })
rerender(<Chat chatList={[]} sidebarCollapseState={false} />)
expect(() => vi.runAllTimers()).not.toThrow()
vi.useRealTimers()
})
it('should not schedule a resize when sidebarCollapseState stays true', () => {
vi.useFakeTimers()
renderChat({ sidebarCollapseState: true })
expect(() => vi.runAllTimers()).not.toThrow()
vi.useRealTimers()
})
})
describe('Edge Cases', () => {
it('should render without crashing with no optional props', () => {
expect(() => render(<Chat chatList={[]} />)).not.toThrow()
})
it('should handle readonly=true without crashing', () => {
expect(() => renderChat({ readonly: true })).not.toThrow()
})
it('should render no modals when both modal flags are false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false, showAgentLogModal: false })
renderChat()
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should render both modals when both flags are true and hideLogModal is false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true, showAgentLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
})
})

View File

@@ -26,7 +26,6 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { cn } from '@/utils/classnames'
import Answer from './answer'
@@ -188,7 +187,6 @@ const Chat: FC<ChatProps> = ({
useEffect(() => {
if (chatFooterRef.current && chatContainerRef.current) {
// container padding bottom
const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]
@@ -198,7 +196,6 @@ const Chat: FC<ChatProps> = ({
})
resizeContainerObserver.observe(chatFooterRef.current)
// footer width
const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]
@@ -237,20 +234,19 @@ const Chat: FC<ChatProps> = ({
return () => container.removeEventListener('scroll', setUserScrolled)
}, [])
// Reset user scroll state when conversation changes or a new chat starts
// Track the first message ID to detect conversation switches (fixes #29820)
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
useEffect(() => {
const firstMessageId = chatList[0]?.id
// Reset when: new chat (length <= 1) OR conversation switched (first message ID changed)
if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId))
userScrolledRef.current = false
prevFirstMessageIdRef.current = firstMessageId
}, [chatList])
useEffect(() => {
if (!sidebarCollapseState)
setTimeout(() => handleWindowResize(), 200)
if (!sidebarCollapseState) {
const timer = setTimeout(() => handleWindowResize(), 200)
return () => clearTimeout(timer)
}
}, [handleWindowResize, sidebarCollapseState])
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
@@ -273,8 +269,9 @@ const Chat: FC<ChatProps> = ({
onFeedback={onFeedback}
getHumanInputNodeData={getHumanInputNodeData}
>
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div data-testid="chat-root" className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div
data-testid="chat-container"
ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
>
@@ -323,6 +320,7 @@ const Chat: FC<ChatProps> = ({
</div>
</div>
<div
data-testid="chat-footer"
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
ref={chatFooterRef}
>
@@ -332,9 +330,10 @@ const Chat: FC<ChatProps> = ({
>
{
!noStopResponding && isResponding && (
<div className="mb-2 flex justify-center">
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
<StopCircle className="mr-[5px] h-3.5 w-3.5" />
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className="i-custom-vender-solid-mediaanddevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
</Button>
</div>

View File

@@ -0,0 +1,22 @@
import { render } from '@testing-library/react'
import LoadingAnim from './index'
describe('LoadingAnim', () => {
it('should render correctly with text type', () => {
const { container } = render(<LoadingAnim type="text" />)
const element = container.firstChild as HTMLElement
expect(element).toBeInTheDocument()
expect(element.className).toMatch(/dot-flashing/)
expect(element.className).toMatch(/text/)
})
it('should render correctly with avatar type', () => {
const { container } = render(<LoadingAnim type="avatar" />)
const element = container.firstChild as HTMLElement
expect(element).toBeInTheDocument()
expect(element.className).toMatch(/dot-flashing/)
expect(element.className).toMatch(/avatar/)
})
})

View File

@@ -0,0 +1,129 @@
import type { IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import Log from './index'
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(),
}))
describe('Log', () => {
const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn()
const mockSetShowAgentLogModal = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
const createLogItem = (overrides?: Partial<IChatItem>): IChatItem => ({
id: '1',
content: 'test',
isAnswer: true, // Required per your IChatItem type
workflow_run_id: '',
agent_thoughts: [],
message_files: [],
...overrides,
})
beforeEach(() => {
vi.mocked(useAppStore).mockImplementation(selector => selector({
// State properties
appSidebarExpand: 'expand',
currentLogModalActiveTab: 'question',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false, // Fixed: Added missing required property
currentLogItem: null,
// Action functions
setCurrentLogItem: mockSetCurrentLogItem,
setShowPromptLogModal: mockSetShowPromptLogModal,
setShowAgentLogModal: mockSetShowAgentLogModal,
setShowMessageLogModal: mockSetShowMessageLogModal,
} as unknown as Parameters<typeof selector>[0])) // Fixed: Double cast to avoid overlap error
})
it('should render correctly', () => {
render(<Log logItem={createLogItem()} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should show message log modal when workflow_run_id exists', async () => {
const user = userEvent.setup()
const logItem = createLogItem({ workflow_run_id: 'run-123' })
render(<Log logItem={logItem} />)
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(true)
})
it('should show agent log modal when agent_thoughts exists and workflow_run_id is missing', async () => {
const user = userEvent.setup()
const thought: ThoughtItem = {
id: 't1',
tool: 'test',
thought: 'thinking',
tool_input: '',
message_id: 'm1',
conversation_id: 'c1',
observation: '',
position: 1,
}
const logItem = createLogItem({
workflow_run_id: '',
agent_thoughts: [thought],
})
render(<Log logItem={logItem} />)
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(true)
})
it('should show prompt log modal when both workflow_run_id and agent_thoughts are missing', async () => {
const user = userEvent.setup()
const logItem = createLogItem({
workflow_run_id: '',
agent_thoughts: [],
})
render(<Log logItem={logItem} />)
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
})
it('should prevent event propagation on click', async () => {
const user = userEvent.setup()
// 1. Spy on both the standard propagation and the immediate propagation
const stopPropagationSpy = vi.spyOn(Event.prototype, 'stopPropagation')
const stopImmediatePropagationSpy = vi.spyOn(Event.prototype, 'stopImmediatePropagation')
render(<Log logItem={createLogItem()} />)
// Find the container div that has the onClick handler
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
// 2. Assert that both were called
expect(stopPropagationSpy).toHaveBeenCalled()
expect(stopImmediatePropagationSpy).toHaveBeenCalled()
// 3. Clean up spies (Good practice to avoid interfering with other tests)
stopPropagationSpy.mockRestore()
stopImmediatePropagationSpy.mockRestore()
})
})

View File

@@ -0,0 +1,267 @@
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import type { ChatConfig, ChatItem, OnRegenerate } from '../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { act, render, screen, waitFor } 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 Toast from '../../toast'
import { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import { ChatContextProvider } from './context'
import Question from './question'
// Global Mocks
vi.mock('@react-aria/interactions', () => ({
useFocusVisible: () => ({ isFocusVisible: false }),
}))
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
// Mock ResizeObserver and capture lifecycle for targeted coverage
const observeMock = vi.fn()
const unobserveMock = vi.fn()
const disconnectMock = vi.fn()
let resizeCallback: ResizeObserverCallback | null = null
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
observe = observeMock
unobserve = unobserveMock
disconnect = disconnectMock
}
vi.stubGlobal('ResizeObserver', MockResizeObserver)
type RenderProps = {
theme?: Theme | null
questionIcon?: React.ReactNode
enableEdit?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
answerIcon?: React.ReactNode
}
const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
id: 'q-1',
content: 'This is the question content',
message_files: [],
siblingCount: 3,
siblingIndex: 0,
prevSibling: null,
nextSibling: 'q-2',
...overrides,
} as unknown as ChatItem)
const renderWithProvider = (
item: ChatItem,
onRegenerate: OnRegenerate = vi.fn() as unknown as OnRegenerate,
props: RenderProps = {},
) => {
return render(
<ChatContextProvider
config={{} as unknown as (ChatConfig | undefined)}
isResponding={false}
chatList={[]}
showPromptLog={false}
questionIcon={props.questionIcon}
answerIcon={props.answerIcon}
onSend={vi.fn()}
onRegenerate={onRegenerate}
onAnnotationEdited={vi.fn()}
onAnnotationAdded={vi.fn()}
onAnnotationRemoved={vi.fn()}
disableFeedback={false}
onFeedback={vi.fn()}
getHumanInputNodeData={vi.fn()}
>
<Question
item={item}
theme={props.theme}
questionIcon={props.questionIcon}
enableEdit={props.enableEdit}
switchSibling={props.switchSibling}
hideAvatar={props.hideAvatar}
/>
</ChatContextProvider>,
)
}
describe('Question component', () => {
beforeEach(() => {
vi.clearAllMocks()
resizeCallback = null
})
it('should render the question content container and default avatar when hideAvatar is false', () => {
const { container } = renderWithProvider(makeItem())
const markdown = container.querySelector('.markdown-body')
expect(markdown).toBeInTheDocument()
const avatar = container.querySelector('.h-10.w-10') || container.querySelector('.h-10.w-10.shrink-0')
expect(avatar).toBeTruthy()
})
it('should hide avatar when hideAvatar is true', () => {
const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { hideAvatar: true })
const avatar = container.querySelector('.h-10.w-10')
expect(avatar).toBeNull()
})
it('should observe content width resize and update layout accurately', () => {
renderWithProvider(makeItem())
expect(observeMock).toHaveBeenCalled()
expect(resizeCallback).not.toBeNull()
// Mock HTML element clientWidth to trigger logic mapping line coverage
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 })
act(() => {
if (resizeCallback) {
resizeCallback([], {} as ResizeObserver)
}
})
const actionContainer = screen.getByTestId('action-container')
// 500 width + 8 offset defined in styles
expect(actionContainer).toHaveStyle({ right: '508px' })
// Restore original
if (originalClientWidth) {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
}
})
it('should disconnect ResizeObserver on component unmount', () => {
const { unmount } = renderWithProvider(makeItem())
unmount()
expect(disconnectMock).toHaveBeenCalled()
})
it('should call copy-to-clipboard and show a toast when copy action is clicked', async () => {
const user = userEvent.setup()
const toastSpy = vi.spyOn(Toast, 'notify')
renderWithProvider(makeItem())
const copyBtn = screen.getByTestId('copy-btn')
await user.click(copyBtn)
await waitFor(() => {
expect(copy).toHaveBeenCalledWith('This is the question content')
expect(toastSpy).toHaveBeenCalled()
})
})
it('should not show edit action when enableEdit is false', () => {
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false })
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
expect(screen.queryByTestId('edit-btn')).not.toBeInTheDocument()
})
it('should enter edit mode when edit action clicked, allow editing and call onRegenerate on resend', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
renderWithProvider(makeItem(), onRegenerate)
const editBtn = screen.getByTestId('edit-btn')
await user.click(editBtn)
const textbox = await screen.findByRole('textbox')
expect(textbox).toHaveValue('This is the question content')
await user.clear(textbox)
await user.type(textbox, 'Edited question')
const resendBtn = screen.getByRole('button', { name: /chat.resend/i })
await user.click(resendBtn)
await waitFor(() => {
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited question', files: [] })
})
})
it('should cancel editing and revert to original markdown when cancel is clicked', async () => {
const user = userEvent.setup()
const { container } = renderWithProvider(makeItem())
const editBtn = screen.getByTestId('edit-btn')
await user.click(editBtn)
const textbox = await screen.findByRole('textbox')
await user.clear(textbox)
await user.type(textbox, 'Edited question')
const cancelBtn = screen.getByRole('button', { name: /operation.cancel/i })
await user.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
const md = container.querySelector('.markdown-body')
expect(md).toBeInTheDocument()
})
})
it('should switch siblings when prev/next buttons are clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
const item = makeItem({ prevSibling: 'q-prev', nextSibling: 'q-next', siblingIndex: 1 })
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
const prevBtn = screen.getByRole('button', { name: /previous/i })
const nextBtn = screen.getByRole('button', { name: /next/i })
await user.click(prevBtn)
await user.click(nextBtn)
expect(switchSibling).toHaveBeenCalledTimes(2)
expect(switchSibling).toHaveBeenCalledWith('q-prev')
expect(switchSibling).toHaveBeenCalledWith('q-next')
})
it('should render prev disabled when no prevSibling is provided', () => {
const item = makeItem({ prevSibling: undefined, nextSibling: 'q-next', siblingIndex: 0, siblingCount: 2 })
renderWithProvider(item, vi.fn() as unknown as OnRegenerate)
const prevBtn = screen.getByRole('button', { name: /previous/i })
const nextBtn = screen.getByRole('button', { name: /next/i })
expect(prevBtn).toBeDisabled()
expect(nextBtn).not.toBeDisabled()
})
it('should render message files block when message_files provided (audio file branch covered)', () => {
const files = [
{
name: 'audio1.mp3',
url: 'https://example.com/audio1.mp3',
type: 'audio/mpeg',
previewUrl: 'https://example.com/audio1.mp3',
size: 1234,
} as unknown as FileEntity,
]
renderWithProvider(makeItem({ message_files: files }))
expect(screen.getByText(/audio1.mp3/i)).toBeInTheDocument()
})
it('should apply theme bubble styles when theme provided', () => {
const themeBuilder = new ThemeBuilder()
themeBuilder.buildTheme('#ff0000', false)
const theme = themeBuilder.theme
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
const contentContainer = screen.getByTestId('question-content')
expect(contentContainer.getAttribute('style')).not.toBeNull()
})
})

View File

@@ -4,7 +4,6 @@ import type {
} from 'react'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import type { ChatItem } from '../types'
import { RiClipboardLine, RiEditLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import {
memo,
@@ -16,7 +15,6 @@ import {
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { FileList } from '@/app/components/base/file-uploader'
import { User } from '@/app/components/base/icons/src/public/avatar'
import { Markdown } from '@/app/components/base/markdown'
import { cn } from '@/utils/classnames'
import ActionButton from '../../action-button'
@@ -107,25 +105,29 @@ const Question: FC<QuestionProps> = ({
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
<div
data-testid="action-container"
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
style={{ right: contentWidth + 8 }}
>
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
<ActionButton
data-testid="copy-btn"
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
<div className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
{enableEdit && (
<ActionButton onClick={handleEdit}>
<RiEditLine className="h-4 w-4" />
<ActionButton data-testid="edit-btn" onClick={handleEdit}>
<div className="i-ri-edit-line h-4 w-4" />
</ActionButton>
)}
</div>
</div>
<div
ref={contentRef}
data-testid="question-content"
className="w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary"
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
@@ -150,7 +152,7 @@ const Question: FC<QuestionProps> = ({
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden">
<Textarea
className={cn(
'body-lg-regular w-full p-1 leading-6 text-text-tertiary outline-none',
'w-full p-1 leading-6 text-text-tertiary outline-none body-lg-regular',
)}
autoFocus
minRows={1}
@@ -181,7 +183,7 @@ const Question: FC<QuestionProps> = ({
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<User className="h-full w-full" />
<div className="i-custom-public-avatar-user h-full w-full" />
</div>
)
}

View File

@@ -0,0 +1,345 @@
import type { ThoughtItem } from '../type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Thought from './index'
describe('Thought', () => {
const createThought = (overrides?: Partial<ThoughtItem>): ThoughtItem => ({
id: 'test-id',
tool: 'test-tool',
tool_input: 'test input',
observation: 'test output',
...overrides,
} as ThoughtItem)
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render single tool thought in collapsed state', () => {
const thought = createThought()
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/used/i)).toBeInTheDocument()
expect(screen.getByText('test-tool')).toBeInTheDocument()
})
it('should render multiple tool thoughts from JSON array', () => {
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={false} />)
expect(screen.getAllByText(/using/i)).toHaveLength(2)
expect(screen.getByText('tool1')).toBeInTheDocument()
expect(screen.getByText('tool2')).toBeInTheDocument()
})
it('should show input and output when expanded', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: 'test input data',
observation: 'test output data',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.queryByText('test input data')).not.toBeInTheDocument()
expect(screen.queryByText('test output data')).not.toBeInTheDocument()
await user.click(screen.getByText(/used/i))
expect(screen.getByText('test input data')).toBeInTheDocument()
expect(screen.getByText('test output data')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show finished state with correct text', () => {
const thought = createThought()
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/used/i)).toBeInTheDocument()
})
it('should show in-progress state with correct text', () => {
const thought = createThought()
render(<Thought thought={thought} isFinished={false} />)
expect(screen.getByText(/using/i)).toBeInTheDocument()
})
})
describe('Tool labels', () => {
it('should use tool name when no labels provided', () => {
const thought = createThought({
tool: 'custom-tool',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('custom-tool')).toBeInTheDocument()
})
it('should fallback to tool name when tool_labels is undefined', () => {
const thought = createThought({
tool: 'fallback-tool',
tool_labels: undefined,
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('fallback-tool')).toBeInTheDocument()
})
it('should fallback to tool name when toolName property is missing', () => {
const thought = createThought({
tool: 'another-tool',
tool_labels: {},
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('another-tool')).toBeInTheDocument()
})
it('should fallback to tool name when language property is missing', () => {
const thought = createThought({
tool: 'test-tool',
tool_labels: {
toolName: {
en_US: 'English Label',
zh_Hans: '中文标签',
},
},
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('test-tool')).toBeInTheDocument()
})
it('should show knowledge label for dataset tools', () => {
const thought = createThought({
tool: 'dataset_123',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
})
})
describe('Value parsing', () => {
it('should handle invalid JSON in tool field', () => {
const thought = createThought({
tool: 'invalid-json-{',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
})
it('should handle non-array JSON in tool field', () => {
const thought = createThought({
tool: JSON.stringify({ name: 'object-tool' }),
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('{"name":"object-tool"}')).toBeInTheDocument()
})
it('should handle invalid JSON in tool_input when parsing array', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1']),
tool_input: 'invalid-json-{',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
})
it('should handle invalid JSON in observation when parsing array', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1']),
observation: 'invalid-json-[',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText('invalid-json-[')).toBeInTheDocument()
})
it('should extract correct values from JSON arrays by index', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2', 'tool3']),
tool_input: JSON.stringify(['input1', 'input2', 'input3']),
observation: JSON.stringify(['output1', 'output2', 'output3']),
})
render(<Thought thought={thought} isFinished={true} />)
const toolSections = screen.getAllByText(/used/i)
expect(toolSections).toHaveLength(3)
await user.click(toolSections[0])
expect(screen.getByText('input1')).toBeInTheDocument()
expect(screen.getByText('output1')).toBeInTheDocument()
await user.click(toolSections[1])
expect(screen.getByText('input2')).toBeInTheDocument()
expect(screen.getByText('output2')).toBeInTheDocument()
await user.click(toolSections[2])
expect(screen.getByText('input3')).toBeInTheDocument()
expect(screen.getByText('output3')).toBeInTheDocument()
})
it('should use original value when isValueArray is false', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: 'single-tool',
tool_input: 'regular input',
observation: 'regular output',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText('regular input')).toBeInTheDocument()
expect(screen.getByText('regular output')).toBeInTheDocument()
})
})
describe('User interactions', () => {
it('should toggle expand state on click', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: 'test input',
observation: 'test output',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.queryByText('test input')).not.toBeInTheDocument()
await user.click(screen.getByText(/used/i))
expect(screen.getByText('test input')).toBeInTheDocument()
await user.click(screen.getByText(/used/i))
expect(screen.queryByText('test input')).not.toBeInTheDocument()
})
it('should expand multiple tools independently', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={true} />)
const toolHeaders = screen.getAllByText(/used/i)
await user.click(toolHeaders[0])
expect(screen.getByText('input1')).toBeInTheDocument()
expect(screen.queryByText('input2')).not.toBeInTheDocument()
await user.click(toolHeaders[1])
expect(screen.getByText('input1')).toBeInTheDocument()
expect(screen.getByText('input2')).toBeInTheDocument()
})
})
describe('Multiple tools with labels', () => {
it('should render multiple tools with dataset prefix', () => {
const thought = createThought({
tool: JSON.stringify(['dataset_123', 'dataset_456']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getAllByText(/knowledge/i)).toHaveLength(2)
})
it('should handle mixed dataset and regular tools', () => {
const thought = createThought({
tool: JSON.stringify(['dataset_123', 'regular-tool']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
expect(screen.getByText('regular-tool')).toBeInTheDocument()
})
})
describe('Edge cases', () => {
it('should handle empty tool_input', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: '',
observation: 'output',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText(/requestTitle/i)).toBeInTheDocument()
})
it('should handle empty observation', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: 'input',
observation: '',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText(/responseTitle/i)).toBeInTheDocument()
})
it('should handle JSON array with undefined elements', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2']),
tool_input: JSON.stringify(['input1']),
observation: JSON.stringify(['output1']),
})
render(<Thought thought={thought} isFinished={true} />)
const toolHeaders = screen.getAllByText(/used/i)
await user.click(toolHeaders[1])
expect(screen.getByText('tool2')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,102 @@
import type { OnSend } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TryToAsk from './try-to-ask'
describe('TryToAsk', () => {
const mockOnSend: OnSend = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the component with header text', () => {
render(
<TryToAsk
suggestedQuestions={['Question 1']}
onSend={mockOnSend}
/>,
)
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
})
it('renders all suggested questions as buttons', () => {
const questions = ['What is AI?', 'How does it work?', 'Tell me more']
render(
<TryToAsk
suggestedQuestions={questions}
onSend={mockOnSend}
/>,
)
questions.forEach((question) => {
expect(screen.getByRole('button', { name: question })).toBeInTheDocument()
})
})
it('calls onSend with the correct question when button is clicked', async () => {
const user = userEvent.setup()
const questions = ['Question 1', 'Question 2', 'Question 3']
render(
<TryToAsk
suggestedQuestions={questions}
onSend={mockOnSend}
/>,
)
await user.click(screen.getByRole('button', { name: 'Question 2' }))
expect(mockOnSend).toHaveBeenCalledTimes(1)
expect(mockOnSend).toHaveBeenCalledWith('Question 2')
})
it('calls onSend for each button click', async () => {
const user = userEvent.setup()
const questions = ['First', 'Second', 'Third']
render(
<TryToAsk
suggestedQuestions={questions}
onSend={mockOnSend}
/>,
)
await user.click(screen.getByRole('button', { name: 'First' }))
await user.click(screen.getByRole('button', { name: 'Third' }))
expect(mockOnSend).toHaveBeenCalledTimes(2)
expect(mockOnSend).toHaveBeenNthCalledWith(1, 'First')
expect(mockOnSend).toHaveBeenNthCalledWith(2, 'Third')
})
it('renders no buttons when suggestedQuestions is empty', () => {
render(
<TryToAsk
suggestedQuestions={[]}
onSend={mockOnSend}
/>,
)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('renders single question correctly', async () => {
const user = userEvent.setup()
const question = 'Single question'
render(
<TryToAsk
suggestedQuestions={[question]}
onSend={mockOnSend}
/>,
)
const button = screen.getByRole('button', { name: question })
expect(button).toBeInTheDocument()
await user.click(button)
expect(mockOnSend).toHaveBeenCalledWith(question)
})
})

View File

@@ -0,0 +1,400 @@
import type { ChatConfig, ChatItem, ChatItemInTree } from '../types'
import type { EmbeddedChatbotContextValue } from './context'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
fetchSuggestedQuestions,
submitHumanInputForm,
} from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { useChat } from '../chat/hooks'
import ChatWrapper from './chat-wrapper'
import { useEmbeddedChatbotContext } from './context'
vi.mock('./context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('../chat/hooks', () => ({
useChat: vi.fn(),
}))
vi.mock('./inputs-form', () => ({
__esModule: true,
default: () => <div>inputs form</div>,
}))
vi.mock('../chat', () => ({
__esModule: true,
default: ({
chatNode,
chatList,
inputDisabled,
questionIcon,
answerIcon,
onSend,
onRegenerate,
switchSibling,
onHumanInputFormSubmit,
onStopResponding,
}: {
chatNode: React.ReactNode
chatList: ChatItem[]
inputDisabled: boolean
questionIcon?: React.ReactNode
answerIcon?: React.ReactNode
onSend: (message: string) => void
onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void
switchSibling: (siblingMessageId: string) => void
onHumanInputFormSubmit: (formToken: string, formData: Record<string, string>) => Promise<void>
onStopResponding: () => void
}) => (
<div>
<div>{chatNode}</div>
{answerIcon}
{chatList.map(item => <div key={item.id}>{item.content}</div>)}
<div>
chat count:
{' '}
{chatList.length}
</div>
{questionIcon}
<button onClick={() => onSend('hello world')}>send through chat</button>
<button onClick={() => onRegenerate({ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' })}>regenerate answer</button>
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
<button disabled={inputDisabled}>send message</button>
<button onClick={onStopResponding}>stop responding</button>
<button onClick={() => onHumanInputFormSubmit('form-token', { answer: 'ok' })}>submit human input</button>
</div>
),
}))
vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/share')>()
return {
...actual,
fetchSuggestedQuestions: vi.fn(),
getUrl: vi.fn(() => '/chat-messages'),
stopChatMessageResponding: vi.fn(),
submitHumanInputForm: vi.fn(),
}
})
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(),
}))
const mockIsDify = vi.fn(() => false)
vi.mock('./utils', () => ({
isDify: () => mockIsDify(),
}))
type UseChatReturn = ReturnType<typeof useChat>
const createContextValue = (overrides: Partial<EmbeddedChatbotContextValue> = {}): EmbeddedChatbotContextValue => ({
appMeta: { tool_icons: {} },
appData: {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
icon_type: 'emoji',
icon: 'bot',
icon_background: '#000000',
icon_url: '',
use_icon_as_answer_icon: false,
},
},
appParams: {} as ChatConfig,
appChatListDataLoading: false,
currentConversationId: '',
currentConversationItem: undefined,
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: vi.fn(),
inputsForms: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
chatShouldReloadKey: 'reload-key',
isMobile: false,
isInstalledApp: false,
appSourceType: AppSourceType.webApp,
allowResetChat: true,
appId: 'app-1',
disableFeedback: false,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
themeBuilder: undefined,
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
setIsResponding: vi.fn(),
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
initUserVariables: {},
...overrides,
})
const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatReturn => ({
chatList: [],
setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'],
handleSend: vi.fn(),
handleResume: vi.fn(),
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
handleStop: vi.fn(),
handleSwitchSibling: vi.fn(),
isResponding: false,
suggestedQuestions: [],
handleRestart: vi.fn(),
handleAnnotationEdited: vi.fn(),
handleAnnotationAdded: vi.fn(),
handleAnnotationRemoved: vi.fn(),
...overrides,
})
describe('EmbeddedChatbot chat-wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
vi.mocked(useChat).mockReturnValue(createUseChatReturn())
})
describe('Welcome behavior', () => {
it('should show opening message and suggested question for a new chat', () => {
const handleSwitchSibling = vi.fn()
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
handleSwitchSibling,
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app', suggestedQuestions: ['How does it work?'] }],
}))
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
appPrevChatList: [
{
id: 'parent-node',
content: 'parent',
isAnswer: true,
children: [
{
id: 'paused-workflow',
content: 'paused',
isAnswer: true,
workflow_run_id: 'run-1',
humanInputFormDataList: [{ label: 'Need info' }],
} as unknown as ChatItem,
],
} as unknown as ChatItem,
],
}))
render(<ChatWrapper />)
expect(screen.getByText('How does it work?')).toBeInTheDocument()
expect(handleSwitchSibling).toHaveBeenCalledWith('paused-workflow', expect.objectContaining({
isPublicAPI: true,
}))
const resumeOptions = handleSwitchSibling.mock.calls[0]?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
resumeOptions.onGetSuggestedQuestions('resume-1')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resume-1', AppSourceType.webApp, 'app-1')
})
it('should hide or show welcome content based on chat state', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'name', label: 'Name', required: true, type: InputVarType.textInput }],
currentConversationId: '',
allInputsHidden: false,
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('Welcome to the app')).not.toBeInTheDocument()
expect(screen.getByText('inputs form')).toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [],
currentConversationId: '',
allInputsHidden: true,
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
chatList: [{ id: 'opening-2', isAnswer: true, isOpeningStatement: true, content: 'Fallback welcome' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
appData: null,
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
isResponding: false,
chatList: [{ id: 'opening-3', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('Should be hidden')).not.toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
isResponding: true,
chatList: [{ id: 'opening-4', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden while responding' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('Should be hidden while responding')).not.toBeInTheDocument()
})
})
describe('Input and avatar behavior', () => {
it('should disable sending when required fields are incomplete or uploading', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'email', label: 'Email', required: true, type: InputVarType.textInput }],
newConversationInputsRef: { current: {} },
}))
render(<ChatWrapper />)
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'file', label: 'File', required: true, type: InputVarType.multiFiles }],
newConversationInputsRef: {
current: {
file: [
{
transferMethod: 'local_file',
},
],
},
},
}))
render(<ChatWrapper />)
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'singleFile', label: 'Single file', required: true, type: InputVarType.singleFile }],
newConversationInputsRef: {
current: {
singleFile: {
transferMethod: 'local_file',
},
},
},
}))
render(<ChatWrapper />)
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
})
it('should show the user name when avatar data is provided', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
initUserVariables: {
avatar_url: 'https://example.com/avatar.png',
name: 'Alice',
},
}))
render(<ChatWrapper />)
expect(screen.getByRole('img', { name: 'Alice' })).toBeInTheDocument()
})
})
describe('Human input submit behavior', () => {
it('should submit via installed app service when the app is installed', async () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
isInstalledApp: true,
}))
render(<ChatWrapper />)
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
await waitFor(() => {
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { answer: 'ok' })
})
expect(submitHumanInputForm).not.toHaveBeenCalled()
})
it('should submit via share service and support chat actions in web app mode', async () => {
const handleSend = vi.fn()
const handleSwitchSibling = vi.fn()
const handleStop = vi.fn()
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
handleSend,
handleSwitchSibling,
handleStop,
chatList: [
{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' },
{ id: 'question-1', isAnswer: false, content: 'Question' },
{ id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' },
] as ChatItemInTree[],
}))
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
isInstalledApp: false,
appSourceType: AppSourceType.tryApp,
isMobile: true,
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
currentConversationId: 'conversation-1',
}))
mockIsDify.mockReturnValue(true)
render(<ChatWrapper />)
expect(screen.getByText('chat count: 3')).toBeInTheDocument()
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'send through chat' }))
fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' }))
fireEvent.click(screen.getByRole('button', { name: 'switch sibling' }))
fireEvent.click(screen.getByRole('button', { name: 'stop responding' }))
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
await waitFor(() => {
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
})
expect(handleSend).toHaveBeenCalledTimes(2)
const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void }
sendOptions.onGetSuggestedQuestions('resp-1')
expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({
isPublicAPI: false,
}))
const switchOptions = handleSwitchSibling.mock.calls.find(call => call[0] === 'sibling-2')?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
switchOptions.onGetSuggestedQuestions('resp-2')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-1', AppSourceType.tryApp, 'app-1')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1')
expect(handleStop).toHaveBeenCalled()
expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
isMobile: true,
currentConversationId: '',
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
chatList: [{ id: 'opening-mobile', isAnswer: true, isOpeningStatement: true, content: 'Mobile welcome' }],
}))
render(<ChatWrapper />)
expect(screen.getByText('inputs form')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,362 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import type { EmbeddedChatbotContextValue } from '../context'
import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { InstallationScope, LicenseStatus } from '@/types/feature'
import { useEmbeddedChatbotContext } from '../context'
import Header from './index'
vi.mock('../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
default: () => <div data-testid="view-form-dropdown" />,
}))
// Mock next/image to render a normal img tag for testing
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
const { unoptimized: _, ...rest } = props
return <img {...rest} />
},
}))
type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
describe('EmbeddedChatbot Header', () => {
const defaultAppData: AppData = {
app_id: 'test-app-id',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'test-user-id',
site: {
title: 'Test Site',
},
}
const defaultContext: Partial<EmbeddedChatbotContextValue> = {
appData: defaultAppData,
currentConversationId: 'test-conv-id',
inputsForms: [],
allInputsHidden: false,
}
const defaultSystemFeatures: SystemFeatures = {
trial_models: [],
plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL,
restrict_to_marketplace_only: false,
},
sso_enforced_for_signin: false,
sso_enforced_for_signin_protocol: '',
sso_enforced_for_web: false,
sso_enforced_for_web_protocol: '',
enable_marketplace: false,
enable_change_email: false,
enable_email_code_login: false,
enable_email_password_login: false,
enable_social_oauth_login: false,
is_allow_create_workspace: false,
is_allow_register: false,
is_email_setup: false,
license: {
status: LicenseStatus.NONE,
expired_at: '',
},
branding: {
enabled: true,
workspace_logo: '',
login_page_logo: '',
favicon: '',
application_title: '',
},
webapp_auth: {
enabled: false,
allow_sso: false,
sso_config: { protocol: '' },
allow_email_code_login: false,
allow_email_password_login: false,
},
enable_trial_app: false,
enable_explore_banner: false,
}
const setupIframe = () => {
const mockPostMessage = vi.fn()
const mockTop = { postMessage: mockPostMessage }
Object.defineProperty(window, 'self', { value: {}, configurable: true })
Object.defineProperty(window, 'top', { value: mockTop, configurable: true })
Object.defineProperty(window, 'parent', { value: mockTop, configurable: true })
return mockPostMessage
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: defaultSystemFeatures,
setSystemFeatures: vi.fn(),
}))
Object.defineProperty(window, 'self', { value: window, configurable: true })
Object.defineProperty(window, 'top', { value: window, configurable: true })
})
describe('Desktop Rendering', () => {
it('should render desktop header with branding by default', async () => {
render(<Header title="Test Chatbot" />)
expect(screen.getByTestId('webapp-brand')).toBeInTheDocument()
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
})
it('should render custom logo when provided in appData', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
appData: {
...defaultAppData,
custom_config: {
...defaultAppData.custom_config,
replace_webapp_logo: 'https://example.com/logo.png',
},
},
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/logo.png')
})
it('should render workspace logo when branding is enabled and logo exists', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
workspace_logo: 'https://example.com/workspace.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/workspace.png')
})
it('should render Dify logo by default when no branding or custom logo is provided', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: false,
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})
it('should NOT render branding when remove_webapp_brand is true', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
appData: {
...defaultAppData,
custom_config: {
...defaultAppData.custom_config,
remove_webapp_brand: true,
},
},
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('webapp-brand')).not.toBeInTheDocument()
})
it('should render reset button when allowResetChat is true and conversation exists', () => {
render(<Header title="Test Chatbot" allowResetChat={true} />)
expect(screen.getByTestId('reset-chat-button')).toBeInTheDocument()
})
it('should call onCreateNewChat when reset button is clicked', async () => {
const user = userEvent.setup()
const onCreateNewChat = vi.fn()
render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />)
await user.click(screen.getByTestId('reset-chat-button'))
expect(onCreateNewChat).toHaveBeenCalled()
})
it('should render ViewFormDropdown when conditions are met', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
inputsForms: [{ id: '1' }],
allInputsHidden: false,
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument()
})
it('should NOT render ViewFormDropdown when inputs are hidden', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
inputsForms: [{ id: '1' }],
allInputsHidden: true,
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
it('should NOT render ViewFormDropdown when currentConversationId is missing', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
currentConversationId: '',
inputsForms: [{ id: '1' }],
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
})
describe('Mobile Rendering', () => {
it('should render mobile header with title', () => {
render(<Header title="Mobile Chatbot" isMobile />)
expect(screen.getByText('Mobile Chatbot')).toBeInTheDocument()
})
it('should render customer icon in mobile header', () => {
render(<Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should render mobile reset button when allowed', () => {
render(<Header title="Mobile Chatbot" isMobile allowResetChat />)
expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument()
})
})
describe('Iframe Communication', () => {
it('should send dify-chatbot-iframe-ready on mount', () => {
const mockPostMessage = setupIframe()
render(<Header title="Iframe" />)
expect(mockPostMessage).toHaveBeenCalledWith(
{ type: 'dify-chatbot-iframe-ready' },
'*',
)
})
it('should update expand button visibility and handle click', async () => {
const user = userEvent.setup()
const mockPostMessage = setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://parent.com',
data: {
type: 'dify-chatbot-config',
payload: { isToggledByButton: true, isDraggable: false },
},
}))
const expandBtn = await screen.findByTestId('expand-button')
expect(expandBtn).toBeInTheDocument()
await user.click(expandBtn)
expect(mockPostMessage).toHaveBeenCalledWith(
{ type: 'dify-chatbot-expand-change' },
'https://parent.com',
)
expect(expandBtn.querySelector('.i-ri-collapse-diagonal-2-line')).toBeInTheDocument()
})
it('should NOT show expand button if isDraggable is true', async () => {
setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://parent.com',
data: {
type: 'dify-chatbot-config',
payload: { isToggledByButton: true, isDraggable: true },
},
}))
await waitFor(() => {
expect(screen.queryByTestId('expand-button')).not.toBeInTheDocument()
})
})
it('should ignore messages from different origins after security lock', async () => {
setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://secure.com',
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: true, isDraggable: false } },
}))
await screen.findByTestId('expand-button')
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://malicious.com',
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: false, isDraggable: false } },
}))
expect(screen.getByTestId('expand-button')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle document.referrer for targetOrigin', () => {
const mockPostMessage = setupIframe()
Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true })
render(<Header title="Referrer" />)
expect(mockPostMessage).toHaveBeenCalledWith(
expect.anything(),
'https://referrer.com',
)
})
it('should NOT add message listener if not in iframe', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
render(<Header title="Direct" />)
expect(addSpy).not.toHaveBeenCalledWith('message', expect.any(Function))
})
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { Theme } from '../theme/theme-context'
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -89,11 +88,13 @@ const Header: FC<IHeaderProps> = ({
{/* powered by */}
<div className="shrink-0">
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}
<div
className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}
data-testid="webapp-brand"
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
@@ -112,11 +113,11 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleToggleExpand}>
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
{
expanded
? <RiCollapseDiagonal2Line className="h-[18px] w-[18px]" />
: <RiExpandDiagonal2Line className="h-[18px] w-[18px]" />
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
}
</ActionButton>
</Tooltip>
@@ -126,8 +127,8 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={onCreateNewChat}>
<RiResetLeftLine className="h-[18px] w-[18px]" />
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
</ActionButton>
</Tooltip>
)}
@@ -147,7 +148,7 @@ const Header: FC<IHeaderProps> = ({
<div className="flex grow items-center space-x-3">
{customerIcon}
<div
className="system-md-semibold truncate"
className="truncate system-md-semibold"
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
>
{title}
@@ -159,11 +160,11 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleToggleExpand}>
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
{
expanded
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
}
</ActionButton>
</Tooltip>
@@ -173,8 +174,8 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={onCreateNewChat}>
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
</ActionButton>
</Tooltip>
)}

View File

@@ -0,0 +1,240 @@
import type { RefObject } from 'react'
import type { ChatConfig } from '../types'
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { defaultSystemFeatures } from '@/types/feature'
import { useEmbeddedChatbot } from './hooks'
import EmbeddedChatbot from './index'
vi.mock('./hooks', () => ({
useEmbeddedChatbot: vi.fn(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('./chat-wrapper', () => ({
__esModule: true,
default: () => <div>chat area</div>,
}))
vi.mock('./header', () => ({
__esModule: true,
default: () => <div>chat header</div>,
}))
vi.mock('./theme/theme-context', () => ({
useThemeContext: vi.fn(() => ({
buildTheme: vi.fn(),
theme: {
backgroundHeaderColorStyle: '',
},
})),
}))
const mockIsDify = vi.fn(() => false)
vi.mock('./utils', () => ({
isDify: () => mockIsDify(),
}))
type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot>
const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): EmbeddedChatbotHookReturn => {
const appData: AppData = {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
chat_color_theme: 'blue',
chat_color_theme_inverted: false,
},
}
const base: EmbeddedChatbotHookReturn = {
appSourceType: 'webApp' as EmbeddedChatbotHookReturn['appSourceType'],
isInstalledApp: false,
appId: 'app-1',
currentConversationId: '',
currentConversationItem: undefined,
removeConversationIdInfo: vi.fn(),
handleConversationIdInfoChange: vi.fn(),
appData,
appParams: {} as ChatConfig,
appMeta: { tool_icons: {} } as AppMeta,
appPinnedConversationData: { data: [], has_more: false, limit: 20 },
appConversationData: { data: [], has_more: false, limit: 20 },
appConversationDataLoading: false,
appChatListData: { data: [], has_more: false, limit: 20 },
appChatListDataLoading: false,
appPrevChatList: [],
pinnedConversationList: [] as ConversationItem[],
conversationList: [] as ConversationItem[],
setShowNewConversationItemInList: vi.fn(),
newConversationInputs: {},
newConversationInputsRef: { current: {} } as unknown as RefObject<Record<string, unknown>>,
handleNewConversationInputsChange: vi.fn(),
inputsForms: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
newConversationId: '',
chatShouldReloadKey: 'reload-key',
allowResetChat: true,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
setIsResponding: vi.fn(),
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
initUserVariables: {},
}
return {
...base,
...overrides,
}
}
describe('EmbeddedChatbot index', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn())
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: '',
},
},
setSystemFeatures: vi.fn(),
}))
})
describe('Loading and chat content', () => {
it('should show loading state before chat content', () => {
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appChatListDataLoading: true }))
render(<EmbeddedChatbot />)
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('chat area')).not.toBeInTheDocument()
})
it('should render chat content when loading finishes', () => {
render(<EmbeddedChatbot />)
expect(screen.getByText('chat area')).toBeInTheDocument()
})
})
describe('Powered by branding', () => {
it('should show workspace logo on mobile when branding is enabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<EmbeddedChatbot />)
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
})
it('should show custom logo when workspace branding logo is unavailable', () => {
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
appData: {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: 'https://example.com/custom-logo.png',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
chat_color_theme: 'blue',
chat_color_theme_inverted: false,
},
},
}))
render(<EmbeddedChatbot />)
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png')
})
it('should hide powered by section when branding is removed', () => {
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
appData: {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: true,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
chat_color_theme: 'blue',
chat_color_theme_inverted: false,
},
},
}))
render(<EmbeddedChatbot />)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
})
it('should not show powered by section on desktop', () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appData: null }))
mockIsDify.mockReturnValue(true)
render(<EmbeddedChatbot />)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
expect(screen.getByText('chat header')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,263 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { useEmbeddedChatbotContext } from '../context'
import InputsFormContent from './content'
vi.mock('../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ token: 'test-token' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: vi.fn() }),
}))
// Mock CodeEditor to trigger onChange easily
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange, placeholder }: { value: string, onChange: (v: string) => void, placeholder: string | React.ReactNode }) => (
<textarea
data-testid="mock-code-editor"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={typeof placeholder === 'string' ? placeholder : 'json-placeholder'}
/>
),
}))
// Mock FileUploaderInAttachmentWrapper to trigger onChange easily
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: any[], onChange: (v: any[]) => void }) => (
<div data-testid="mock-file-uploader">
<button onClick={() => onChange([new File([''], 'test.png', { type: 'image/png' })])}>Upload</button>
<span>{value.length > 0 ? value[0].name : 'no file'}</span>
</div>
),
}))
const mockContextValue = {
appParams: {
system_parameters: {
file_size_limit: 10,
},
},
inputsForms: [
{
variable: 'text_var',
label: 'Text Label',
type: InputVarType.textInput,
required: true,
},
{
variable: 'num_var',
label: 'Number Label',
type: InputVarType.number,
required: false,
},
{
variable: 'para_var',
label: 'Paragraph Label',
type: InputVarType.paragraph,
required: true,
},
{
variable: 'bool_var',
label: 'Bool Label',
type: InputVarType.checkbox,
required: true,
},
{
variable: 'select_var',
label: 'Select Label',
type: InputVarType.select,
options: ['Option 1', 'Option 2'],
required: true,
},
{
variable: 'file_var',
label: 'File Label',
type: InputVarType.singleFile,
required: true,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_upload'],
},
{
variable: 'multi_file_var',
label: 'Multi File Label',
type: InputVarType.multiFiles,
required: true,
max_length: 5,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_upload'],
},
{
variable: 'json_var',
label: 'JSON Label',
type: InputVarType.jsonObject,
required: true,
json_schema: '{ "type": "object" }',
},
{
variable: 'hidden_var',
label: 'Hidden Label',
type: InputVarType.textInput,
hide: true,
},
],
currentConversationId: null,
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: vi.fn(),
}
describe('InputsFormContent', () => {
const user = userEvent.setup()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
})
it('should render visible input forms', () => {
render(<InputsFormContent />)
expect(screen.getAllByText(/Text Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Number Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Paragraph Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Bool Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Select Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/File Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Multi File Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/JSON Label/i).length).toBeGreaterThan(0)
expect(screen.queryByText('Hidden Label')).not.toBeInTheDocument()
})
it('should render optional label for non-required fields', () => {
render(<InputsFormContent />)
expect(screen.queryAllByText(/panel.optional/i).length).toBeGreaterThan(0)
})
it('should handle text input changes', async () => {
render(<InputsFormContent />)
const inputs = screen.getAllByPlaceholderText('Text Label')
await user.type(inputs[0], 'hello')
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle number input changes', async () => {
render(<InputsFormContent />)
const inputs = screen.getAllByPlaceholderText('Number Label')
await user.type(inputs[0], '123')
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle paragraph input changes', async () => {
render(<InputsFormContent />)
const inputs = screen.getAllByPlaceholderText('Paragraph Label')
await user.type(inputs[0], 'long text')
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle bool input changes', async () => {
render(<InputsFormContent />)
const checkbox = screen.getByTestId(/checkbox-/i)
await user.click(checkbox)
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle select input changes', async () => {
render(<InputsFormContent />)
const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN')
if (!selectTrigger)
throw new Error('Select trigger not found')
await user.click(selectTrigger)
const option = screen.getByText('Option 1')
await user.click(option)
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle single file upload change', async () => {
render(<InputsFormContent />)
const uploadButtons = screen.getAllByText('Upload')
await user.click(uploadButtons[0]) // First one is single file
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle multi files upload change', async () => {
render(<InputsFormContent />)
const uploadButtons = screen.getAllByText('Upload')
await user.click(uploadButtons[1]) // Second one is multi files
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle JSON object change', async () => {
render(<InputsFormContent />)
const jsonEditor = screen.getByTestId('mock-code-editor')
fireEvent.change(jsonEditor, { target: { value: '{ "a": 1 }' } })
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should show tip when showTip is true', () => {
render(<InputsFormContent showTip />)
expect(screen.getByText(/chat.chatFormTip/i)).toBeInTheDocument()
})
it('should set initial values from context', () => {
const contextWithValues = {
...mockContextValue,
newConversationInputs: {
text_var: 'initial value',
},
}
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithValues as unknown as any)
render(<InputsFormContent />)
expect(screen.getByDisplayValue('initial value')).toBeInTheDocument()
})
it('should use currentConversationInputs when currentConversationId exists', () => {
const contextWithConv = {
...mockContextValue,
currentConversationId: 'conv-id',
currentConversationInputs: {
text_var: 'conv value',
},
}
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithConv as unknown as any)
render(<InputsFormContent />)
expect(screen.getByDisplayValue('conv value')).toBeInTheDocument()
})
})

View File

@@ -45,12 +45,12 @@ const InputsFormContent = ({ showTip }: Props) => {
return (
<div className="space-y-4">
{visibleInputsForms.map(form => (
<div key={form.variable} className="space-y-1">
<div key={form.variable} className="space-y-1" data-testid={`inputs-form-item-${form.variable}`}>
{form.type !== InputVarType.checkbox && (
<div className="flex h-6 items-center gap-1">
<div className="system-md-semibold text-text-secondary">{form.label}</div>
<div className="text-text-secondary system-md-semibold">{form.label}</div>
{!form.required && (
<div className="system-xs-regular text-text-tertiary">{t('panel.optional', { ns: 'workflow' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('panel.optional', { ns: 'workflow' })}</div>
)}
</div>
)}
@@ -125,7 +125,7 @@ const InputsFormContent = ({ showTip }: Props) => {
value={inputsFormValue?.[form.variable] || ''}
onChange={v => handleFormChange(form.variable, v)}
noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
<div className="whitespace-pre">{form.json_schema}</div>
}
@@ -134,7 +134,7 @@ const InputsFormContent = ({ showTip }: Props) => {
</div>
))}
{showTip && (
<div className="system-xs-regular text-text-tertiary">{t('chat.chatFormTip', { ns: 'share' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('chat.chatFormTip', { ns: 'share' })}</div>
)}
</div>
)

View File

@@ -0,0 +1,121 @@
/* eslint-disable ts/no-explicit-any */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AppSourceType } from '@/service/share'
import { useEmbeddedChatbotContext } from '../context'
import InputsFormNode from './index'
vi.mock('../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
// Mock InputsFormContent to avoid complex integration in this test
vi.mock('./content', () => ({
default: () => <div data-testid="mock-inputs-form-content" />,
}))
const mockContextValue = {
appSourceType: AppSourceType.webApp,
isMobile: false,
currentConversationId: null,
themeBuilder: null,
handleStartChat: vi.fn(),
allInputsHidden: false,
inputsForms: [{ variable: 'test' }],
}
describe('InputsFormNode', () => {
const user = userEvent.setup()
const setCollapsed = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
})
it('should return null if allInputsHidden is true', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
allInputsHidden: true,
} as unknown as any)
const { container } = render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
expect(container.firstChild).toBeNull()
})
it('should return null if inputsForms is empty', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
inputsForms: [],
} as unknown as any)
const { container } = render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
expect(container.firstChild).toBeNull()
})
it('should render expanded state correctly', () => {
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
expect(screen.getByTestId('inputs-form-start-chat-button')).toBeInTheDocument()
})
it('should render collapsed state correctly', () => {
render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
expect(screen.queryByTestId('mock-inputs-form-content')).not.toBeInTheDocument()
expect(screen.getByTestId('inputs-form-edit-button')).toBeInTheDocument()
})
it('should handle edit button click', async () => {
render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
await user.click(screen.getByTestId('inputs-form-edit-button'))
expect(setCollapsed).toHaveBeenCalledWith(false)
})
it('should handle close button click', async () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
currentConversationId: 'conv-123',
} as unknown as any)
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
await user.click(screen.getByTestId('inputs-form-close-button'))
expect(setCollapsed).toHaveBeenCalledWith(true)
})
it('should handle start chat button click', async () => {
const handleStartChat = vi.fn(cb => cb())
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
handleStartChat,
} as unknown as any)
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
await user.click(screen.getByTestId('inputs-form-start-chat-button'))
expect(handleStartChat).toHaveBeenCalled()
expect(setCollapsed).toHaveBeenCalledWith(true)
})
it('should apply theme primary color to start chat button', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
themeBuilder: {
theme: {
primaryColor: '#ff0000',
},
},
} as unknown as any)
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
const button = screen.getByTestId('inputs-form-start-chat-button')
expect(button).toHaveStyle({ backgroundColor: '#ff0000' })
})
it('should apply tryApp styles when appSourceType is tryApp', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
appSourceType: AppSourceType.tryApp,
} as unknown as any)
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
const mainDiv = screen.getByTestId('inputs-form-node')
expect(mainDiv).toHaveClass('mb-0 px-0')
})
})

View File

@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import Divider from '@/app/components/base/divider'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import { useEmbeddedChatbotContext } from '../context'
@@ -33,7 +32,10 @@ const InputsFormNode = ({
return null
return (
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}>
<div
data-testid="inputs-form-node"
className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}
>
<div className={cn(
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
@@ -46,13 +48,29 @@ const InputsFormNode = ({
isMobile && 'px-4 py-3',
)}
>
<Message3Fill className="h-6 w-6 shrink-0" />
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
{collapsed && (
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(false)}>{t('operation.edit', { ns: 'common' })}</Button>
<Button
className="uppercase text-text-tertiary"
size="small"
variant="ghost"
onClick={() => setCollapsed(false)}
data-testid="inputs-form-edit-button"
>
{t('operation.edit', { ns: 'common' })}
</Button>
)}
{!collapsed && currentConversationId && (
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(true)}>{t('operation.close', { ns: 'common' })}</Button>
<Button
className="uppercase text-text-tertiary"
size="small"
variant="ghost"
onClick={() => setCollapsed(true)}
data-testid="inputs-form-close-button"
>
{t('operation.close', { ns: 'common' })}
</Button>
)}
</div>
{!collapsed && (
@@ -66,6 +84,7 @@ const InputsFormNode = ({
variant="primary"
className="w-full"
onClick={() => handleStartChat(() => setCollapsed(true))}
data-testid="inputs-form-start-chat-button"
style={
themeBuilder?.theme
? {

View File

@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ViewFormDropdown from './view-form-dropdown'
// Mock InputsFormContent to avoid complex integration in this test
vi.mock('./content', () => ({
default: () => <div data-testid="mock-inputs-form-content" />,
}))
// Note: PortalToFollowElem is mocked globally in vitest.setup.ts
// to render children in the normal DOM flow when open is true.
describe('ViewFormDropdown', () => {
const user = userEvent.setup()
it('should render the trigger button', () => {
render(<ViewFormDropdown />)
expect(screen.getByTestId('view-form-dropdown-trigger')).toBeInTheDocument()
})
it('should not show content initially', () => {
render(<ViewFormDropdown />)
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
})
it('should show content when trigger is clicked', async () => {
render(<ViewFormDropdown />)
await user.click(screen.getByTestId('view-form-dropdown-trigger'))
expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
})
it('should close content when trigger is clicked again', async () => {
render(<ViewFormDropdown />)
const trigger = screen.getByTestId('view-form-dropdown-trigger')
await user.click(trigger) // Open
expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
await user.click(trigger) // Close
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
})
it('should apply iconColor class to the icon', async () => {
render(<ViewFormDropdown iconColor="text-red-500" />)
await user.click(screen.getByTestId('view-form-dropdown-trigger'))
const icon = screen.getByTestId('view-form-dropdown-trigger').querySelector('.i-ri-chat-settings-line')
expect(icon).toHaveClass('text-red-500')
})
})

View File

@@ -1,18 +1,18 @@
import {
RiChatSettingsLine,
} from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
type Props = {
iconColor?: string
}
const ViewFormDropdown = ({ iconColor }: Props) => {
const ViewFormDropdown = ({
iconColor,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -26,18 +26,23 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
crossAxis: 4,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<ActionButton
size="l"
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
data-testid="view-form-dropdown-trigger"
>
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[99]">
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm">
<div
data-testid="view-form-dropdown-content"
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm"
>
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
<Message3Fill className="h-6 w-6 shrink-0" />
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
</div>
<div className="p-6">
<InputsFormContent />
@@ -45,7 +50,6 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react'
import IndeterminateIcon from './indeterminate-icon'
describe('IndeterminateIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<IndeterminateIcon />)
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
})
it('should render an svg element', () => {
const { container } = render(<IndeterminateIcon />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
})

View File

@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
it('should render year options', () => {
const props = createOptionsProps()
render(<Options {...props} />)
const { container } = render(<Options {...props} />)
const allItems = screen.getAllByRole('listitem')
expect(allItems).toHaveLength(212)
const yearList = container.querySelectorAll('ul')[1]
expect(yearList?.children).toHaveLength(200)
})
})

View File

@@ -1,6 +1,5 @@
'use client'
import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import Button from '../button'
@@ -81,7 +80,18 @@ export default function Drawer({
)}
{showClose && (
<DialogTitle className="mb-4 flex cursor-pointer items-center" as="div">
<XMarkIcon className="h-4 w-4 text-text-tertiary" onClick={onClose} />
<span
className="i-heroicons-x-mark h-4 w-4 text-text-tertiary"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
onClose()
}}
role="button"
tabIndex={0}
aria-label={t('operation.close', { ns: 'common' })}
data-testid="close-icon"
/>
</DialogTitle>
)}
</div>

View File

@@ -0,0 +1,383 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index'
const mockConfig = vi.hoisted(() => ({
isDev: false,
}))
vi.mock('@/config', () => ({
get IS_DEV() {
return mockConfig.isDev
},
}))
type ThrowOnRenderProps = {
message?: string
shouldThrow: boolean
}
const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => {
if (shouldThrow)
throw new Error(message)
return <div>Child content rendered</div>
}
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.isDev = false
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
// Verify default render and default fallback behavior.
describe('Rendering', () => {
it('should render children when no error occurs', () => {
render(
<ErrorBoundary>
<ThrowOnRender shouldThrow={false} />
</ErrorBoundary>,
)
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
})
it('should render default fallback with title and message when child throws', async () => {
render(
<ErrorBoundary>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument()
})
it('should render custom title, message, and className in fallback', async () => {
render(
<ErrorBoundary
className="custom-boundary"
customMessage="Custom recovery message"
customTitle="Custom crash title"
isolate={false}
>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
expect(await screen.findByText('Custom crash title')).toBeInTheDocument()
expect(screen.getByText('Custom recovery message')).toBeInTheDocument()
const fallbackRoot = document.querySelector('.custom-boundary')
expect(fallbackRoot).toBeInTheDocument()
expect(fallbackRoot).not.toHaveClass('min-h-[200px]')
})
})
// Validate explicit fallback prop variants.
describe('Fallback props', () => {
it('should render node fallback when fallback prop is a React node', async () => {
render(
<ErrorBoundary fallback={<div>Node fallback content</div>}>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
expect(await screen.findByText('Node fallback content')).toBeInTheDocument()
})
it('should render function fallback with error message when fallback prop is a function', async () => {
render(
<ErrorBoundary
fallback={error => (
<div>
Function fallback:
{' '}
{error.message}
</div>
)}
>
<ThrowOnRender message="function fallback boom" shouldThrow={true} />
</ErrorBoundary>,
)
expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument()
})
})
// Validate error reporting and details panel behavior.
describe('Error reporting', () => {
it('should call onError with error and errorInfo when child throws', async () => {
const onError = vi.fn()
render(
<ErrorBoundary onError={onError}>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
await screen.findByText('Something went wrong')
expect(onError).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'render boom' }),
expect.objectContaining({ componentStack: expect.any(String) }),
)
})
it('should render details block when showDetails is true', async () => {
render(
<ErrorBoundary showDetails={true}>
<ThrowOnRender message="details boom" shouldThrow={true} />
</ErrorBoundary>,
)
expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument()
expect(screen.getByText('Error:')).toBeInTheDocument()
expect(screen.getByText(/details boom/i)).toBeInTheDocument()
})
it('should log boundary errors in development mode', async () => {
mockConfig.isDev = true
render(
<ErrorBoundary>
<ThrowOnRender message="dev boom" shouldThrow={true} />
</ErrorBoundary>,
)
await screen.findByText('Something went wrong')
expect(consoleErrorSpy).toHaveBeenCalledWith(
'ErrorBoundary caught an error:',
expect.objectContaining({ message: 'dev boom' }),
)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error Info:',
expect.objectContaining({ componentStack: expect.any(String) }),
)
})
})
// Validate recovery controls and automatic reset triggers.
describe('Recovery', () => {
it('should hide recovery actions when enableRecovery is false', async () => {
render(
<ErrorBoundary enableRecovery={false}>
<ThrowOnRender shouldThrow={true} />
</ErrorBoundary>,
)
await screen.findByText('Something went wrong')
expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument()
})
it('should reset and render children when Try Again is clicked', async () => {
const onReset = vi.fn()
const RecoveryHarness = () => {
const [shouldThrow, setShouldThrow] = React.useState(true)
return (
<ErrorBoundary
onReset={() => {
onReset()
setShouldThrow(false)
}}
>
<ThrowOnRender shouldThrow={shouldThrow} />
</ErrorBoundary>
)
}
render(<RecoveryHarness />)
fireEvent.click(await screen.findByRole('button', { name: 'Try Again' }))
await screen.findByText('Child content rendered')
expect(onReset).toHaveBeenCalledTimes(1)
})
it('should reset after resetKeys change when boundary is in error state', async () => {
const ResetKeysHarness = () => {
const [shouldThrow, setShouldThrow] = React.useState(true)
const [boundaryKey, setBoundaryKey] = React.useState(0)
return (
<>
<button
onClick={() => {
setShouldThrow(false)
setBoundaryKey(1)
}}
>
Recover with keys
</button>
<ErrorBoundary resetKeys={[boundaryKey]}>
<ThrowOnRender shouldThrow={shouldThrow} />
</ErrorBoundary>
</>
)
}
render(<ResetKeysHarness />)
await screen.findByText('Something went wrong')
fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' }))
await waitFor(() => {
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
})
})
it('should reset after children change when resetOnPropsChange is true', async () => {
const ResetOnPropsHarness = () => {
const [shouldThrow, setShouldThrow] = React.useState(true)
const [childLabel, setChildLabel] = React.useState('first child')
return (
<>
<button
onClick={() => {
setShouldThrow(false)
setChildLabel('second child')
}}
>
Replace children
</button>
<ErrorBoundary resetOnPropsChange={true}>
{shouldThrow ? <ThrowOnRender shouldThrow={true} /> : <div>{childLabel}</div>}
</ErrorBoundary>
</>
)
}
render(<ResetOnPropsHarness />)
await screen.findByText('Something went wrong')
fireEvent.click(screen.getByRole('button', { name: 'Replace children' }))
await waitFor(() => {
expect(screen.getByText('second child')).toBeInTheDocument()
})
})
})
})
describe('ErrorBoundary utility exports', () => {
beforeEach(() => {
vi.clearAllMocks()
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
// Validate imperative error hook behavior.
describe('useErrorHandler', () => {
it('should trigger error boundary fallback when setError is called', async () => {
const HookConsumer = () => {
const setError = useErrorHandler()
return (
<button onClick={() => setError(new Error('handler boom'))}>
Trigger hook error
</button>
)
}
render(
<ErrorBoundary fallback={<div>Hook fallback shown</div>}>
<HookConsumer />
</ErrorBoundary>,
)
fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
})
})
// Validate async error bridge hook behavior.
describe('useAsyncError', () => {
it('should trigger error boundary fallback when async error callback is called', async () => {
const AsyncHookConsumer = () => {
const throwAsyncError = useAsyncError()
return (
<button onClick={() => throwAsyncError(new Error('async hook boom'))}>
Trigger async hook error
</button>
)
}
render(
<ErrorBoundary fallback={<div>Async fallback shown</div>}>
<AsyncHookConsumer />
</ErrorBoundary>,
)
fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
})
})
// Validate HOC wrapper behavior and metadata.
describe('withErrorBoundary', () => {
it('should wrap component and render custom title when wrapped component throws', async () => {
type WrappedProps = {
shouldThrow: boolean
}
const WrappedTarget = ({ shouldThrow }: WrappedProps) => {
if (shouldThrow)
throw new Error('wrapped boom')
return <div>Wrapped content</div>
}
const Wrapped = withErrorBoundary(WrappedTarget, {
customTitle: 'Wrapped boundary title',
})
render(<Wrapped shouldThrow={true} />)
expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument()
})
it('should set displayName using wrapped component name', () => {
const NamedComponent = () => <div>named content</div>
const Wrapped = withErrorBoundary(NamedComponent)
expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)')
})
})
// Validate simple fallback helper component.
describe('ErrorFallback', () => {
it('should render message and call reset action when button is clicked', () => {
const resetErrorBoundaryAction = vi.fn()
render(
<ErrorFallback
error={new Error('fallback helper message')}
resetErrorBoundaryAction={resetErrorBoundaryAction}
/>,
)
expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
expect(screen.getByText('fallback helper message')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { useContext } from 'react'
import { FeaturesContext, FeaturesProvider } from './context'
const TestConsumer = () => {
const store = useContext(FeaturesContext)
if (!store)
return <div>no store</div>
const { features } = store.getState()
return <div role="status">{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}</div>
}
describe('FeaturesProvider', () => {
it('should provide store to children when FeaturesProvider wraps them', () => {
render(
<FeaturesProvider>
<TestConsumer />
</FeaturesProvider>,
)
expect(screen.getByRole('status')).toHaveTextContent('disabled')
})
it('should provide initial features state when features prop is provided', () => {
render(
<FeaturesProvider features={{ moreLikeThis: { enabled: true } }}>
<TestConsumer />
</FeaturesProvider>,
)
expect(screen.getByRole('status')).toHaveTextContent('enabled')
})
it('should maintain the same store reference across re-renders', () => {
const storeRefs: Array<ReturnType<typeof useContext>> = []
const StoreRefCollector = () => {
const store = useContext(FeaturesContext)
storeRefs.push(store)
return null
}
const { rerender } = render(
<FeaturesProvider>
<StoreRefCollector />
</FeaturesProvider>,
)
rerender(
<FeaturesProvider>
<StoreRefCollector />
</FeaturesProvider>,
)
expect(storeRefs[0]).toBe(storeRefs[1])
})
it('should handle empty features object', () => {
render(
<FeaturesProvider features={{}}>
<TestConsumer />
</FeaturesProvider>,
)
expect(screen.getByRole('status')).toHaveTextContent('disabled')
})
})

View File

@@ -0,0 +1,63 @@
import { renderHook } from '@testing-library/react'
import * as React from 'react'
import { FeaturesContext } from './context'
import { useFeatures, useFeaturesStore } from './hooks'
import { createFeaturesStore } from './store'
describe('useFeatures', () => {
it('should return selected state from the store when useFeatures is called with selector', () => {
const store = createFeaturesStore({
features: { moreLikeThis: { enabled: true } },
})
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(FeaturesContext.Provider, { value: store }, children)
const { result } = renderHook(
() => useFeatures(s => s.features.moreLikeThis?.enabled),
{ wrapper },
)
expect(result.current).toBe(true)
})
it('should throw error when used outside FeaturesContext.Provider', () => {
// Act & Assert
expect(() => {
renderHook(() => useFeatures(s => s.features))
}).toThrow('Missing FeaturesContext.Provider in the tree')
})
it('should return undefined when feature does not exist', () => {
const store = createFeaturesStore({ features: {} })
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(FeaturesContext.Provider, { value: store }, children)
const { result } = renderHook(
() => useFeatures(s => (s.features as Record<string, unknown>).nonexistent as boolean | undefined),
{ wrapper },
)
expect(result.current).toBeUndefined()
})
})
describe('useFeaturesStore', () => {
it('should return the store from context when used within provider', () => {
const store = createFeaturesStore()
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(FeaturesContext.Provider, { value: store }, children)
const { result } = renderHook(() => useFeaturesStore(), { wrapper })
expect(result.current).toBe(store)
})
it('should return null when used outside provider', () => {
const { result } = renderHook(() => useFeaturesStore())
expect(result.current).toBeNull()
})
})

View File

@@ -0,0 +1,149 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AnnotationCtrlButton from './annotation-ctrl-button'
const mockSetShowAnnotationFullModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
}),
}))
let mockAnnotatedResponseUsage = 5
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: {
usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } },
total: { annotatedResponse: 100 },
},
enableBilling: true,
}),
}))
const mockAddAnnotation = vi.fn().mockResolvedValue({
id: 'annotation-1',
account: { name: 'Test User' },
})
vi.mock('@/service/annotation', () => ({
addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
describe('AnnotationCtrlButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAnnotatedResponseUsage = 5
})
it('should render edit button when cached', () => {
render(
<AnnotationCtrlButton
appId="test-app"
cached={true}
query="test query"
answer="test answer"
onAdded={vi.fn()}
onEdit={vi.fn()}
/>,
)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call onEdit when edit button is clicked', () => {
const onEdit = vi.fn()
render(
<AnnotationCtrlButton
appId="test-app"
cached={true}
query="test query"
answer="test answer"
onAdded={vi.fn()}
onEdit={onEdit}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(onEdit).toHaveBeenCalled()
})
it('should render add button when not cached and has answer', () => {
render(
<AnnotationCtrlButton
appId="test-app"
cached={false}
query="test query"
answer="test answer"
onAdded={vi.fn()}
onEdit={vi.fn()}
/>,
)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not render any button when not cached and no answer', () => {
render(
<AnnotationCtrlButton
appId="test-app"
cached={false}
query="test query"
answer=""
onAdded={vi.fn()}
onEdit={vi.fn()}
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should call addAnnotation and onAdded when add button is clicked', async () => {
const onAdded = vi.fn()
render(
<AnnotationCtrlButton
appId="test-app"
messageId="msg-1"
cached={false}
query="test query"
answer="test answer"
onAdded={onAdded}
onEdit={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', {
message_id: 'msg-1',
question: 'test query',
answer: 'test answer',
})
expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User')
})
})
it('should show annotation full modal when annotation limit is reached', () => {
mockAnnotatedResponseUsage = 100
render(
<AnnotationCtrlButton
appId="test-app"
cached={false}
query="test query"
answer="test answer"
onAdded={vi.fn()}
onEdit={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
expect(mockAddAnnotation).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,415 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import ConfigParamModal from './config-param-modal'
let mockHooksReturn: {
modelList: { provider: { provider: string }, models: { model: string }[] }[]
defaultModel: { provider: { provider: string }, model: string } | undefined
currentModel: boolean | undefined
} = {
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
currentModel: true,
}
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
ModelTypeEnum: {
textEmbedding: 'text-embedding',
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector" data-provider={defaultModel?.provider} data-model={defaultModel?.model}>
Model Selector
<button data-testid="select-model" onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })}>Select</button>
</div>
),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/config', () => ({
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
}))
const defaultAnnotationConfig = {
id: 'test-id',
enabled: false,
score_threshold: 0.9,
embedding_model: {
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-ada-002',
},
}
describe('ConfigParamModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHooksReturn = {
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
currentModel: true,
}
})
it('should not render when isShow is false', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={false}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.queryByText(/initSetup/)).not.toBeInTheDocument()
})
it('should render init title when isInit is true', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
isInit={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
})
it('should render config title when isInit is false', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
isInit={false}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByText(/initSetup\.configTitle/)).toBeInTheDocument()
})
it('should render score slider', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should render model selector', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should render cancel and confirm buttons', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
isInit={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
})
it('should display score threshold value', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByText('0.90')).toBeInTheDocument()
})
it('should render configConfirmBtn when isInit is false', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
isInit={false}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
expect(screen.getByText(/initSetup\.configConfirmBtn/)).toBeInTheDocument()
})
it('should call onSave with embedding model and score when save is clicked', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={onSave}
annotationConfig={defaultAnnotationConfig}
/>,
)
// Click the confirm/save button
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
{ embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
0.9,
)
})
})
it('should show error toast when embedding model is not set', () => {
const configWithoutModel = {
...defaultAnnotationConfig,
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
}
// Override hooks to return no default model and no valid current model
mockHooksReturn = {
modelList: [],
defaultModel: undefined,
currentModel: undefined,
}
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={configWithoutModel}
/>,
)
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should call onHide when cancel is clicked and not loading', () => {
const onHide = vi.fn()
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={onHide}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
fireEvent.click(screen.getByText(/operation\.cancel/))
expect(onHide).toHaveBeenCalled()
})
it('should render slider with expected bounds and current value', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '80')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '90')
})
it('should update embedding model when model selector is used', () => {
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={defaultAnnotationConfig}
/>,
)
// Click the select model button in mock
fireEvent.click(screen.getByTestId('select-model'))
// Model selector should now show the new provider/model
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'cohere')
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'embed-english')
})
it('should call onSave with updated score from annotation config', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={onSave}
annotationConfig={{
...defaultAnnotationConfig,
score_threshold: 0.95,
}}
/>,
)
// Save
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ embedding_provider_name: 'openai' }),
0.95,
)
})
})
it('should call onSave with updated model after model selector change', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={onSave}
annotationConfig={defaultAnnotationConfig}
/>,
)
// Change model
fireEvent.click(screen.getByTestId('select-model'))
// Save
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
{ embedding_provider_name: 'cohere', embedding_model_name: 'embed-english' },
0.9,
)
})
})
it('should use default model when annotation config has no embedding model', () => {
const configWithoutModel = {
...defaultAnnotationConfig,
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
}
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={configWithoutModel}
/>,
)
// Model selector should be initialized with the default model
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai')
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
})
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
const configWithoutThreshold = {
...defaultAnnotationConfig,
score_threshold: 0,
}
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={configWithoutThreshold}
/>,
)
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90')
})
it('should set loading state while saving', async () => {
let resolveOnSave: () => void
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
resolveOnSave = resolve
}))
const onHide = vi.fn()
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={onHide}
onSave={onSave}
annotationConfig={defaultAnnotationConfig}
/>,
)
// Click save
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
// While loading, clicking cancel should not call onHide
fireEvent.click(screen.getByText(/operation\.cancel/))
expect(onHide).not.toHaveBeenCalled()
// Resolve the save
resolveOnSave!()
await waitFor(() => {
expect(onSave).toHaveBeenCalled()
})
})
})

Some files were not shown because too many files have changed in this diff Show More