Compare commits

..

9 Commits

Author SHA1 Message Date
Coding On Star
a4e03d6284 test: add integration tests for app card operations, list browsing, and create app flows (#32298)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-13 13:21:09 +08:00
Poojan
84d090db33 test: add unit tests for base components-part-1 (#32154) 2026-02-13 11:14:14 +08:00
dependabot[bot]
f3f56f03e3 chore(deps): bump qs from 6.14.1 to 6.14.2 in /web (#32290)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 10:48:08 +08:00
Coding On Star
b6d506828b test(web): add and enhance frontend automated tests across multiple modules (#32268)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-13 10:27:48 +08:00
Conner Mo
16df9851a2 feat(api): optimize OceanBase vector store performance and configurability (#32263)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-13 09:48:55 +08:00
Bowen Liang
c0ffb6db2a feat: support config max size of plugin generated files (#30887)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 09:48:27 +08:00
dependabot[bot]
0118b45cff chore(deps): bump pillow from 12.0.0 to 12.1.1 in /api (#32250)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 04:47:19 +09:00
Stephen Zhou
8fd3eeb760 fix: can not upload file in single run (#32276) 2026-02-12 17:23:01 +08:00
Varun Chawla
f233e2036f fix: metadata batch edit silently fails due to split transactions and swallowed exceptions (#32041) 2026-02-12 12:59:59 +08:00
114 changed files with 8920 additions and 4547 deletions

View File

@@ -265,6 +265,11 @@ class PluginConfig(BaseSettings):
default=60 * 60,
)
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
description="Maximum allowed size (bytes) for plugin-generated files",
default=50 * 1024 * 1024,
)
class MarketplaceConfig(BaseSettings):
"""

View File

@@ -1,3 +1,5 @@
from typing import Literal
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
@@ -49,3 +51,43 @@ class OceanBaseVectorConfig(BaseSettings):
),
default="ik",
)
OCEANBASE_VECTOR_BATCH_SIZE: PositiveInt = Field(
description="Number of documents to insert per batch",
default=100,
)
OCEANBASE_VECTOR_METRIC_TYPE: Literal["l2", "cosine", "inner_product"] = Field(
description="Distance metric type for vector index: l2, cosine, or inner_product",
default="l2",
)
OCEANBASE_HNSW_M: PositiveInt = Field(
description="HNSW M parameter (max number of connections per node)",
default=16,
)
OCEANBASE_HNSW_EF_CONSTRUCTION: PositiveInt = Field(
description="HNSW efConstruction parameter (index build-time search width)",
default=256,
)
OCEANBASE_HNSW_EF_SEARCH: int = Field(
description="HNSW efSearch parameter (query-time search width, -1 uses server default)",
default=-1,
)
OCEANBASE_VECTOR_POOL_SIZE: PositiveInt = Field(
description="SQLAlchemy connection pool size",
default=5,
)
OCEANBASE_VECTOR_MAX_OVERFLOW: int = Field(
description="SQLAlchemy connection pool max overflow connections",
default=10,
)
OCEANBASE_HNSW_REFRESH_THRESHOLD: int = Field(
description="Minimum number of inserted documents to trigger an automatic HNSW index refresh (0 to disable)",
default=1000,
)

View File

@@ -3,6 +3,8 @@ from typing import Any
from pydantic import BaseModel
from configs import dify_config
# from core.plugin.entities.plugin import GenericProviderID, ToolProviderID
from core.plugin.entities.plugin_daemon import CredentialType, PluginBasicBooleanResponse, PluginToolProviderEntity
from core.plugin.impl.base import BasePluginClient
@@ -122,7 +124,7 @@ class PluginToolManager(BasePluginClient):
},
)
return merge_blob_chunks(response)
return merge_blob_chunks(response, max_file_size=dify_config.PLUGIN_MAX_FILE_SIZE)
def validate_provider_credentials(
self, tenant_id: str, user_id: str, provider: str, credentials: dict[str, Any]

View File

@@ -1,12 +1,13 @@
import json
import logging
import math
from typing import Any
import re
from typing import Any, Literal
from pydantic import BaseModel, model_validator
from pyobvector import VECTOR, ObVecClient, l2_distance # type: ignore
from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_distance # type: ignore
from sqlalchemy import JSON, Column, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.exc import SQLAlchemyError
from configs import dify_config
from core.rag.datasource.vdb.vector_base import BaseVector
@@ -19,10 +20,14 @@ from models.dataset import Dataset
logger = logging.getLogger(__name__)
DEFAULT_OCEANBASE_HNSW_BUILD_PARAM = {"M": 16, "efConstruction": 256}
DEFAULT_OCEANBASE_HNSW_SEARCH_PARAM = {"efSearch": 64}
OCEANBASE_SUPPORTED_VECTOR_INDEX_TYPE = "HNSW"
DEFAULT_OCEANBASE_VECTOR_METRIC_TYPE = "l2"
_VALID_TABLE_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$")
_DISTANCE_FUNC_MAP = {
"l2": l2_distance,
"cosine": cosine_distance,
"inner_product": inner_product,
}
class OceanBaseVectorConfig(BaseModel):
@@ -32,6 +37,14 @@ class OceanBaseVectorConfig(BaseModel):
password: str
database: str
enable_hybrid_search: bool = False
batch_size: int = 100
metric_type: Literal["l2", "cosine", "inner_product"] = "l2"
hnsw_m: int = 16
hnsw_ef_construction: int = 256
hnsw_ef_search: int = -1
pool_size: int = 5
max_overflow: int = 10
hnsw_refresh_threshold: int = 1000
@model_validator(mode="before")
@classmethod
@@ -49,14 +62,23 @@ class OceanBaseVectorConfig(BaseModel):
class OceanBaseVector(BaseVector):
def __init__(self, collection_name: str, config: OceanBaseVectorConfig):
if not _VALID_TABLE_NAME_RE.match(collection_name):
raise ValueError(
f"Invalid collection name '{collection_name}': "
"only alphanumeric characters and underscores are allowed."
)
super().__init__(collection_name)
self._config = config
self._hnsw_ef_search = -1
self._hnsw_ef_search = self._config.hnsw_ef_search
self._client = ObVecClient(
uri=f"{self._config.host}:{self._config.port}",
user=self._config.user,
password=self._config.password,
db_name=self._config.database,
pool_size=self._config.pool_size,
max_overflow=self._config.max_overflow,
pool_recycle=3600,
pool_pre_ping=True,
)
self._fields: list[str] = [] # List of fields in the collection
if self._client.check_table_exists(collection_name):
@@ -136,8 +158,8 @@ class OceanBaseVector(BaseVector):
field_name="vector",
index_type=OCEANBASE_SUPPORTED_VECTOR_INDEX_TYPE,
index_name="vector_index",
metric_type=DEFAULT_OCEANBASE_VECTOR_METRIC_TYPE,
params=DEFAULT_OCEANBASE_HNSW_BUILD_PARAM,
metric_type=self._config.metric_type,
params={"M": self._config.hnsw_m, "efConstruction": self._config.hnsw_ef_construction},
)
self._client.create_table_with_index_params(
@@ -178,6 +200,17 @@ class OceanBaseVector(BaseVector):
else:
logger.debug("DEBUG: Hybrid search is NOT enabled for '%s'", self._collection_name)
try:
self._client.perform_raw_text_sql(
f"CREATE INDEX IF NOT EXISTS idx_metadata_doc_id ON `{self._collection_name}` "
f"((CAST(metadata->>'$.document_id' AS CHAR(64))))"
)
except SQLAlchemyError:
logger.warning(
"Failed to create metadata functional index on '%s'; metadata queries may be slow without it.",
self._collection_name,
)
self._client.refresh_metadata([self._collection_name])
self._load_collection_fields()
redis_client.set(collection_exist_cache_key, 1, ex=3600)
@@ -205,24 +238,49 @@ class OceanBaseVector(BaseVector):
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
ids = self._get_uuids(documents)
for id, doc, emb in zip(ids, documents, embeddings):
batch_size = self._config.batch_size
total = len(documents)
all_data = [
{
"id": doc_id,
"vector": emb,
"text": doc.page_content,
"metadata": doc.metadata,
}
for doc_id, doc, emb in zip(ids, documents, embeddings)
]
for start in range(0, total, batch_size):
batch = all_data[start : start + batch_size]
try:
self._client.insert(
table_name=self._collection_name,
data={
"id": id,
"vector": emb,
"text": doc.page_content,
"metadata": doc.metadata,
},
data=batch,
)
except Exception as e:
logger.exception(
"Failed to insert document with id '%s' in collection '%s'",
id,
"Failed to insert batch [%d:%d] into collection '%s'",
start,
start + len(batch),
self._collection_name,
)
raise Exception(
f"Failed to insert batch [{start}:{start + len(batch)}] into collection '{self._collection_name}'"
) from e
if self._config.hnsw_refresh_threshold > 0 and total >= self._config.hnsw_refresh_threshold:
try:
self._client.refresh_index(
table_name=self._collection_name,
index_name="vector_index",
)
except SQLAlchemyError:
logger.warning(
"Failed to refresh HNSW index after inserting %d documents into '%s'",
total,
self._collection_name,
)
raise Exception(f"Failed to insert document with id '{id}'") from e
def text_exists(self, id: str) -> bool:
try:
@@ -412,7 +470,7 @@ class OceanBaseVector(BaseVector):
vec_column_name="vector",
vec_data=query_vector,
topk=topk,
distance_func=l2_distance,
distance_func=self._get_distance_func(),
output_column_names=["text", "metadata"],
with_dist=True,
where_clause=_where_clause,
@@ -424,14 +482,31 @@ class OceanBaseVector(BaseVector):
)
raise Exception(f"Vector search failed for collection '{self._collection_name}'") from e
# Convert distance to score and prepare results for processing
results = []
for _text, metadata_str, distance in cur:
score = 1 - distance / math.sqrt(2)
score = self._distance_to_score(distance)
results.append((_text, metadata_str, score))
return self._process_search_results(results, score_threshold=score_threshold)
def _get_distance_func(self):
func = _DISTANCE_FUNC_MAP.get(self._config.metric_type)
if func is None:
raise ValueError(
f"Unsupported metric_type '{self._config.metric_type}'. Supported: {', '.join(_DISTANCE_FUNC_MAP)}"
)
return func
def _distance_to_score(self, distance: float) -> float:
metric = self._config.metric_type
if metric == "l2":
return 1.0 / (1.0 + distance)
elif metric == "cosine":
return 1.0 - distance
elif metric == "inner_product":
return -distance
raise ValueError(f"Unsupported metric_type '{metric}'")
def delete(self):
try:
self._client.drop_table_if_exist(self._collection_name)
@@ -464,5 +539,13 @@ class OceanBaseVectorFactory(AbstractVectorFactory):
password=(dify_config.OCEANBASE_VECTOR_PASSWORD or ""),
database=dify_config.OCEANBASE_VECTOR_DATABASE or "",
enable_hybrid_search=dify_config.OCEANBASE_ENABLE_HYBRID_SEARCH or False,
batch_size=dify_config.OCEANBASE_VECTOR_BATCH_SIZE,
metric_type=dify_config.OCEANBASE_VECTOR_METRIC_TYPE,
hnsw_m=dify_config.OCEANBASE_HNSW_M,
hnsw_ef_construction=dify_config.OCEANBASE_HNSW_EF_CONSTRUCTION,
hnsw_ef_search=dify_config.OCEANBASE_HNSW_EF_SEARCH,
pool_size=dify_config.OCEANBASE_VECTOR_POOL_SIZE,
max_overflow=dify_config.OCEANBASE_VECTOR_MAX_OVERFLOW,
hnsw_refresh_threshold=dify_config.OCEANBASE_HNSW_REFRESH_THRESHOLD,
),
)

View File

@@ -67,7 +67,7 @@ dependencies = [
"pycryptodome==3.23.0",
"pydantic~=2.11.4",
"pydantic-extra-types~=2.10.3",
"pydantic-settings~=2.11.0",
"pydantic-settings~=2.12.0",
"pyjwt~=2.10.1",
"pypdfium2==5.2.0",
"python-docx~=1.1.0",

View File

@@ -220,8 +220,8 @@ class MetadataService:
doc_metadata[BuiltInField.source] = MetadataDataSource[document.data_source_type]
document.doc_metadata = doc_metadata
db.session.add(document)
db.session.commit()
# deal metadata binding
# deal metadata binding (in the same transaction as the doc_metadata update)
if not operation.partial_update:
db.session.query(DatasetMetadataBinding).filter_by(document_id=operation.document_id).delete()
@@ -247,7 +247,9 @@ class MetadataService:
db.session.add(dataset_metadata_binding)
db.session.commit()
except Exception:
db.session.rollback()
logger.exception("Update documents metadata failed")
raise
finally:
redis_client.delete(lock_key)

View File

@@ -0,0 +1,241 @@
"""
Benchmark: OceanBase vector store — old (single-row) vs new (batch) insertion,
metadata query with/without functional index, and vector search across metrics.
Usage:
uv run --project api python -m tests.integration_tests.vdb.oceanbase.bench_oceanbase
"""
import json
import random
import statistics
import time
import uuid
from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_distance
from sqlalchemy import JSON, Column, String, text
from sqlalchemy.dialects.mysql import LONGTEXT
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HOST = "127.0.0.1"
PORT = 2881
USER = "root@test"
PASSWORD = "difyai123456"
DATABASE = "test"
VEC_DIM = 1536
HNSW_BUILD = {"M": 16, "efConstruction": 256}
DISTANCE_FUNCS = {"l2": l2_distance, "cosine": cosine_distance, "inner_product": inner_product}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_client(**extra):
return ObVecClient(
uri=f"{HOST}:{PORT}",
user=USER,
password=PASSWORD,
db_name=DATABASE,
**extra,
)
def _rand_vec():
return [random.uniform(-1, 1) for _ in range(VEC_DIM)] # noqa: S311
def _drop(client, table):
client.drop_table_if_exist(table)
def _create_table(client, table, metric="l2"):
cols = [
Column("id", String(36), primary_key=True, autoincrement=False),
Column("vector", VECTOR(VEC_DIM)),
Column("text", LONGTEXT),
Column("metadata", JSON),
]
vidx = client.prepare_index_params()
vidx.add_index(
field_name="vector",
index_type="HNSW",
index_name="vector_index",
metric_type=metric,
params=HNSW_BUILD,
)
client.create_table_with_index_params(table_name=table, columns=cols, vidxs=vidx)
client.refresh_metadata([table])
def _gen_rows(n):
doc_id = str(uuid.uuid4())
rows = []
for _ in range(n):
rows.append(
{
"id": str(uuid.uuid4()),
"vector": _rand_vec(),
"text": f"benchmark text {uuid.uuid4().hex[:12]}",
"metadata": json.dumps({"document_id": doc_id, "dataset_id": str(uuid.uuid4())}),
}
)
return rows, doc_id
# ---------------------------------------------------------------------------
# Benchmark: Insertion
# ---------------------------------------------------------------------------
def bench_insert_single(client, table, rows):
"""Old approach: one INSERT per row."""
t0 = time.perf_counter()
for row in rows:
client.insert(table_name=table, data=row)
return time.perf_counter() - t0
def bench_insert_batch(client, table, rows, batch_size=100):
"""New approach: batch INSERT."""
t0 = time.perf_counter()
for start in range(0, len(rows), batch_size):
batch = rows[start : start + batch_size]
client.insert(table_name=table, data=batch)
return time.perf_counter() - t0
# ---------------------------------------------------------------------------
# Benchmark: Metadata query
# ---------------------------------------------------------------------------
def bench_metadata_query(client, table, doc_id, with_index=False):
"""Query by metadata->>'$.document_id' with/without functional index."""
if with_index:
try:
client.perform_raw_text_sql(f"CREATE INDEX idx_metadata_doc_id ON `{table}` ((metadata->>'$.document_id'))")
except Exception:
pass # already exists
sql = text(f"SELECT id FROM `{table}` WHERE metadata->>'$.document_id' = :val")
times = []
with client.engine.connect() as conn:
for _ in range(10):
t0 = time.perf_counter()
result = conn.execute(sql, {"val": doc_id})
_ = result.fetchall()
times.append(time.perf_counter() - t0)
return times
# ---------------------------------------------------------------------------
# Benchmark: Vector search
# ---------------------------------------------------------------------------
def bench_vector_search(client, table, metric, topk=10, n_queries=20):
dist_func = DISTANCE_FUNCS[metric]
times = []
for _ in range(n_queries):
q = _rand_vec()
t0 = time.perf_counter()
cur = client.ann_search(
table_name=table,
vec_column_name="vector",
vec_data=q,
topk=topk,
distance_func=dist_func,
output_column_names=["text", "metadata"],
with_dist=True,
)
_ = list(cur)
times.append(time.perf_counter() - t0)
return times
def _fmt(times):
"""Format list of durations as 'mean ± stdev'."""
m = statistics.mean(times) * 1000
s = statistics.stdev(times) * 1000 if len(times) > 1 else 0
return f"{m:.1f} ± {s:.1f} ms"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
client = _make_client()
client_pooled = _make_client(pool_size=5, max_overflow=10, pool_recycle=3600, pool_pre_ping=True)
print("=" * 70)
print("OceanBase Vector Store — Performance Benchmark")
print(f" Endpoint : {HOST}:{PORT}")
print(f" Vec dim : {VEC_DIM}")
print("=" * 70)
# ------------------------------------------------------------------
# 1. Insertion benchmark
# ------------------------------------------------------------------
for n_docs in [100, 500, 1000]:
rows, doc_id = _gen_rows(n_docs)
tbl_single = f"bench_single_{n_docs}"
tbl_batch = f"bench_batch_{n_docs}"
_drop(client, tbl_single)
_drop(client, tbl_batch)
_create_table(client, tbl_single)
_create_table(client, tbl_batch)
t_single = bench_insert_single(client, tbl_single, rows)
t_batch = bench_insert_batch(client_pooled, tbl_batch, rows, batch_size=100)
speedup = t_single / t_batch if t_batch > 0 else float("inf")
print(f"\n[Insert {n_docs} docs]")
print(f" Single-row : {t_single:.2f}s")
print(f" Batch(100) : {t_batch:.2f}s")
print(f" Speedup : {speedup:.1f}x")
# ------------------------------------------------------------------
# 2. Metadata query benchmark (use the 1000-doc batch table)
# ------------------------------------------------------------------
tbl_meta = "bench_batch_1000"
rows_1000, doc_id_1000 = _gen_rows(1000)
# The table already has 1000 rows from step 1; use that doc_id
# Re-query doc_id from one of the rows we inserted
with client.engine.connect() as conn:
res = conn.execute(text(f"SELECT metadata->>'$.document_id' FROM `{tbl_meta}` LIMIT 1"))
doc_id_1000 = res.fetchone()[0]
print("\n[Metadata filter query — 1000 rows, by document_id]")
times_no_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=False)
print(f" Without index : {_fmt(times_no_idx)}")
times_with_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=True)
print(f" With index : {_fmt(times_with_idx)}")
# ------------------------------------------------------------------
# 3. Vector search benchmark — across metrics
# ------------------------------------------------------------------
print("\n[Vector search — top-10, 20 queries each, on 1000 rows]")
for metric in ["l2", "cosine", "inner_product"]:
tbl_vs = f"bench_vs_{metric}"
_drop(client_pooled, tbl_vs)
_create_table(client_pooled, tbl_vs, metric=metric)
# Insert 1000 rows
rows_vs, _ = _gen_rows(1000)
bench_insert_batch(client_pooled, tbl_vs, rows_vs, batch_size=100)
times = bench_vector_search(client_pooled, tbl_vs, metric, topk=10, n_queries=20)
print(f" {metric:15s}: {_fmt(times)}")
_drop(client_pooled, tbl_vs)
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
for n in [100, 500, 1000]:
_drop(client, f"bench_single_{n}")
_drop(client, f"bench_batch_{n}")
print("\n" + "=" * 70)
print("Benchmark complete.")
print("=" * 70)
if __name__ == "__main__":
main()

View File

@@ -21,6 +21,7 @@ def oceanbase_vector():
database="test",
password="difyai123456",
enable_hybrid_search=True,
batch_size=10,
),
)

View File

@@ -914,9 +914,6 @@ class TestMetadataService:
metadata_args = MetadataArgs(type="string", name="test_metadata")
metadata = MetadataService.create_metadata(dataset.id, metadata_args)
# Mock DocumentService.get_document to return None (document not found)
mock_external_service_dependencies["document_service"].get_document.return_value = None
# Create metadata operation data
from services.entities.knowledge_entities.knowledge_entities import (
DocumentMetadataOperation,
@@ -926,16 +923,17 @@ class TestMetadataService:
metadata_detail = MetadataDetail(id=metadata.id, name=metadata.name, value="test_value")
operation = DocumentMetadataOperation(document_id="non-existent-document-id", metadata_list=[metadata_detail])
# Use a valid UUID format that does not exist in the database
operation = DocumentMetadataOperation(
document_id="00000000-0000-0000-0000-000000000000", metadata_list=[metadata_detail]
)
operation_data = MetadataOperationData(operation_data=[operation])
# Act: Execute the method under test
# The method should handle the error gracefully and continue
MetadataService.update_documents_metadata(dataset, operation_data)
# Assert: Verify the method completes without raising exceptions
# The main functionality (error handling) is verified
# Act & Assert: The method should raise ValueError("Document not found.")
# because the exception is now re-raised after rollback
with pytest.raises(ValueError, match="Document not found"):
MetadataService.update_documents_metadata(dataset, operation_data)
def test_knowledge_base_metadata_lock_check_dataset_id(
self, db_session_with_containers, mock_external_service_dependencies

View File

@@ -1,6 +1,8 @@
import unittest
from unittest.mock import MagicMock, patch
import pytest
from models.dataset import Dataset, Document
from services.entities.knowledge_entities.knowledge_entities import (
DocumentMetadataOperation,
@@ -148,6 +150,38 @@ class TestMetadataPartialUpdate(unittest.TestCase):
# If it were added, there would be 2 calls. If skipped, 1 call.
assert mock_db.session.add.call_count == 1
@patch("services.metadata_service.db")
@patch("services.metadata_service.DocumentService")
@patch("services.metadata_service.current_account_with_tenant")
@patch("services.metadata_service.redis_client")
def test_rollback_called_on_commit_failure(self, mock_redis, mock_current_account, mock_document_service, mock_db):
"""When db.session.commit() raises, rollback must be called and the exception must propagate."""
# Setup mocks
mock_redis.get.return_value = None
mock_document_service.get_document.return_value = self.document
mock_current_account.return_value = (MagicMock(id="user_id"), "tenant_id")
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
# Make commit raise an exception
mock_db.session.commit.side_effect = RuntimeError("database connection lost")
operation = DocumentMetadataOperation(
document_id="doc_id",
metadata_list=[MetadataDetail(id="meta_id", name="key", value="value")],
partial_update=True,
)
metadata_args = MetadataOperationData(operation_data=[operation])
# Act & Assert: the exception must propagate
with pytest.raises(RuntimeError, match="database connection lost"):
MetadataService.update_documents_metadata(self.dataset, metadata_args)
# Verify rollback was called
mock_db.session.rollback.assert_called_once()
# Verify the lock key was cleaned up despite the failure
mock_redis.delete.assert_called_with("document_metadata_lock_doc_id")
if __name__ == "__main__":
unittest.main()

70
api/uv.lock generated
View File

@@ -1635,7 +1635,7 @@ requires-dist = [
{ name = "pycryptodome", specifier = "==3.23.0" },
{ name = "pydantic", specifier = "~=2.11.4" },
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
{ name = "pydantic-settings", specifier = "~=2.11.0" },
{ name = "pydantic-settings", specifier = "~=2.12.0" },
{ name = "pyjwt", specifier = "~=2.10.1" },
{ name = "pypdfium2", specifier = "==5.2.0" },
{ name = "python-docx", specifier = "~=1.1.0" },
@@ -4473,39 +4473,39 @@ wheels = [
[[package]]
name = "pillow"
version = "12.0.0"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
]
[[package]]
@@ -4900,16 +4900,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.11.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" }
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" },
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]

View File

@@ -0,0 +1,459 @@
/**
* Integration test: App Card Operations Flow
*
* Tests the end-to-end user flows for app card operations:
* - Editing app info
* - Duplicating an app
* - Deleting an app
* - Exporting app DSL
* - Navigation on card click
* - Access mode icons
*/
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppCard from '@/app/components/apps/app-card'
import { AccessMode } from '@/models/access-control'
import { deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
const mockRouterPush = vi.fn()
const mockNotify = vi.fn()
const mockOnPlanInfoChanged = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
// Mock headless UI Popover so it renders content without transition
vi.mock('@headlessui/react', async () => {
const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react')
return {
...actual,
Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => (
<div className={className} data-testid="popover-wrapper">
{typeof children === 'function' ? children({ open: true }) : children}
</div>
),
PopoverButton: ({ children, className, ref: _ref, ...rest }: Record<string, unknown>) => (
<button className={className as string} {...rest}>{children as React.ReactNode}</button>
),
PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => (
<div className={className}>
{typeof children === 'function' ? children({ close: vi.fn() }) : children}
</div>
),
Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
vi.mock('next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
loader().then((mod) => {
Component = mod.default as React.ComponentType<Record<string, unknown>>
}).catch(() => {})
const Wrapper = (props: Record<string, unknown>) => {
if (Component)
return <Component {...props} />
return null
}
Wrapper.displayName = 'DynamicWrapper'
return Wrapper
},
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = { systemFeatures: mockSystemFeatures }
if (typeof selector === 'function')
return selector(state)
return mockSystemFeatures
},
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
// Mock the ToastContext used via useContext from use-context-selector
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
tagList: [],
showTagManagementModal: false,
setTagList: vi.fn(),
setShowTagManagementModal: vi.fn(),
}
return selector(state)
},
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/apps', () => ({
deleteApp: vi.fn().mockResolvedValue({}),
updateAppInfo: vi.fn().mockResolvedValue({}),
copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }),
exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// Mock modals loaded via next/dynamic
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => {
if (!show)
return null
return (
<div data-testid="edit-app-modal">
<span data-testid="modal-app-name">{appName as string}</span>
<button
data-testid="confirm-edit"
onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({
name: 'Updated App Name',
icon_type: 'emoji',
icon: '🔥',
icon_background: '#fff',
description: 'Updated description',
})}
>
Confirm
</button>
<button data-testid="cancel-edit" onClick={onHide as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/app/duplicate-modal', () => ({
default: ({ show, onConfirm, onHide }: Record<string, unknown>) => {
if (!show)
return null
return (
<div data-testid="duplicate-app-modal">
<button
data-testid="confirm-duplicate"
onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({
name: 'Copied App',
icon_type: 'emoji',
icon: '📋',
icon_background: '#fff',
})}
>
Confirm Duplicate
</button>
<button data-testid="cancel-duplicate" onClick={onHide as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/app/switch-app-modal', () => ({
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
if (!show)
return null
return (
<div data-testid="switch-app-modal">
<button data-testid="confirm-switch" onClick={onSuccess as () => void}>Confirm Switch</button>
<button data-testid="cancel-switch" onClick={onClose as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: Record<string, unknown>) => {
if (!isShow)
return null
return (
<div data-testid="confirm-delete-modal">
<span>{title as string}</span>
<button data-testid="confirm-delete" onClick={onConfirm as () => void}>Delete</button>
<button data-testid="cancel-delete" onClick={onCancel as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="dsl-export-confirm-modal">
<button data-testid="export-include" onClick={() => (onConfirm as (include: boolean) => void)(true)}>Include</button>
<button data-testid="export-close" onClick={onClose as () => void}>Close</button>
</div>
),
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="access-control-modal">
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>
</div>
),
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'Test Chat App',
description: overrides.description ?? 'A chat application',
author_name: overrides.author_name ?? 'Test Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? {} as App['model_config'],
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? {} as App['site'],
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
})
const mockOnRefresh = vi.fn()
const renderAppCard = (app?: Partial<App>) => {
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
}
describe('App Card Operations Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceEditor = true
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
})
// -- Basic rendering --
describe('Card Rendering', () => {
it('should render app name and description', () => {
renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' })
expect(screen.getByText('My AI Bot')).toBeInTheDocument()
expect(screen.getByText('An intelligent assistant')).toBeInTheDocument()
})
it('should render author name', () => {
renderAppCard({ author_name: 'John Doe' })
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should navigate to app config page when card is clicked', () => {
renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT })
const card = screen.getByText('Test Chat App').closest('[class*="cursor-pointer"]')
if (card)
fireEvent.click(card)
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/configuration')
})
it('should navigate to workflow page for workflow apps', () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
const card = screen.getByText('WF App').closest('[class*="cursor-pointer"]')
if (card)
fireEvent.click(card)
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-wf/workflow')
})
})
// -- Delete flow --
describe('Delete App Flow', () => {
it('should show delete confirmation and call API on confirm', async () => {
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
// Find and click the more button (popover trigger)
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const deleteBtn = screen.queryByText('common.operation.delete')
if (deleteBtn)
fireEvent.click(deleteBtn)
})
const confirmBtn = screen.queryByTestId('confirm-delete')
if (confirmBtn) {
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(deleteApp).toHaveBeenCalledWith('app-to-delete')
})
}
}
})
})
// -- Edit flow --
describe('Edit App Flow', () => {
it('should open edit modal and call updateAppInfo on confirm', async () => {
renderAppCard({ id: 'app-edit', name: 'Editable App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const editBtn = screen.queryByText('app.editApp')
if (editBtn)
fireEvent.click(editBtn)
})
const confirmEdit = screen.queryByTestId('confirm-edit')
if (confirmEdit) {
fireEvent.click(confirmEdit)
await waitFor(() => {
expect(updateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
}
}
})
})
// -- Export flow --
describe('Export App Flow', () => {
it('should call exportAppConfig for completion apps', async () => {
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const exportBtn = screen.queryByText('app.export')
if (exportBtn)
fireEvent.click(exportBtn)
})
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
}
})
})
// -- Access mode display --
describe('Access Mode Display', () => {
it('should not render operations menu for non-editor users', () => {
mockIsCurrentWorkspaceEditor = false
renderAppCard({ name: 'Readonly App' })
expect(screen.queryByText('app.editApp')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
// -- Switch mode (only for CHAT/COMPLETION) --
describe('Switch App Mode', () => {
it('should show switch option for chat mode apps', async () => {
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.queryByText('app.switch')).toBeInTheDocument()
})
}
})
it('should not show switch option for workflow apps', async () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
}
})
})
})

View File

@@ -0,0 +1,439 @@
/**
* Integration test: App List Browsing Flow
*
* Tests the end-to-end user flow of browsing, filtering, searching,
* and tab switching in the apps list page.
*
* Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true
let mockIsCurrentWorkspaceDatasetOperator = false
let mockIsLoadingCurrentWorkspace = false
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
let mockPages: AppListResponse[] = []
let mockIsLoading = false
let mockIsFetching = false
let mockIsFetchingNextPage = false
let mockHasNextPage = false
let mockError: Error | null = null
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
let mockShowTagManagementModal = false
const mockRouterPush = vi.fn()
const mockRouterReplace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('next/dynamic', () => ({
default: (_loader: () => Promise<{ default: React.ComponentType }>) => {
const LazyComponent = (props: Record<string, unknown>) => {
return <div data-testid="dynamic-component" {...props} />
}
LazyComponent.displayName = 'DynamicComponent'
return LazyComponent
},
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator,
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = { systemFeatures: mockSystemFeatures }
return selector ? selector(state) : state
},
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: vi.fn(),
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
tagList: [],
showTagManagementModal: mockShowTagManagementModal,
setTagList: vi.fn(),
setShowTagManagementModal: vi.fn(),
}
return selector(state)
},
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
error: mockError,
refetch: mockRefetch,
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: (...args: unknown[]) => fnRef.current(...args),
}
},
}
})
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'My Chat App',
description: overrides.description ?? 'A chat application',
author_name: overrides.author_name ?? 'Test Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? {} as App['model_config'],
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? {} as App['site'],
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
})
const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({
data: apps,
has_more: hasMore,
limit: 30,
page,
total: apps.length,
})
const renderList = (searchParams?: Record<string, string>) => {
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
}
describe('App List Browsing Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceEditor = true
mockIsCurrentWorkspaceDatasetOperator = false
mockIsLoadingCurrentWorkspace = false
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
mockPages = []
mockIsLoading = false
mockIsFetching = false
mockIsFetchingNextPage = false
mockHasNextPage = false
mockError = null
mockShowTagManagementModal = false
})
// -- Loading and Empty states --
describe('Loading and Empty States', () => {
it('should show skeleton cards during initial loading', () => {
mockIsLoading = true
renderList()
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
})
it('should show empty state when no apps exist', () => {
mockPages = [createPage([])]
renderList()
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should transition from loading to content when data loads', () => {
mockIsLoading = true
const { rerender } = render(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
// Data loads
mockIsLoading = false
mockPages = [createPage([
createMockApp({ id: 'app-1', name: 'Loaded App' }),
])]
rerender(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
expect(screen.getByText('Loaded App')).toBeInTheDocument()
})
})
// -- Rendering apps --
describe('App List Rendering', () => {
it('should render all app cards from the data', () => {
mockPages = [createPage([
createMockApp({ id: 'app-1', name: 'Chat Bot' }),
createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }),
createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }),
])]
renderList()
expect(screen.getByText('Chat Bot')).toBeInTheDocument()
expect(screen.getByText('Workflow Engine')).toBeInTheDocument()
expect(screen.getByText('Completion Tool')).toBeInTheDocument()
})
it('should display app descriptions', () => {
mockPages = [createPage([
createMockApp({ name: 'My App', description: 'A powerful AI assistant' }),
])]
renderList()
expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument()
})
it('should show the NewAppCard for workspace editors', () => {
mockPages = [createPage([
createMockApp({ name: 'Test App' }),
])]
renderList()
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
it('should hide NewAppCard when user is not a workspace editor', () => {
mockIsCurrentWorkspaceEditor = false
mockPages = [createPage([
createMockApp({ name: 'Test App' }),
])]
renderList()
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
})
})
// -- Footer visibility --
describe('Footer Visibility', () => {
it('should show footer when branding is disabled', () => {
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } }
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.join')).toBeInTheDocument()
expect(screen.getByText('app.communityIntro')).toBeInTheDocument()
})
it('should hide footer when branding is enabled', () => {
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } }
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.join')).not.toBeInTheDocument()
})
})
// -- DSL drag-drop hint --
describe('DSL Drag-Drop Hint', () => {
it('should show drag-drop hint for workspace editors', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should hide drag-drop hint for non-editors', () => {
mockIsCurrentWorkspaceEditor = false
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
})
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render all category tabs', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
})
// -- Search --
describe('Search Filtering', () => {
it('should render search input', () => {
mockPages = [createPage([createMockApp()])]
renderList()
const input = document.querySelector('input')
expect(input).toBeInTheDocument()
})
it('should allow typing in search input', () => {
mockPages = [createPage([createMockApp()])]
renderList()
const input = document.querySelector('input')!
fireEvent.change(input, { target: { value: 'test search' } })
expect(input.value).toBe('test search')
})
})
// -- "Created by me" filter --
describe('Created By Me Filter', () => {
it('should render the "created by me" checkbox', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should toggle the "created by me" filter on click', () => {
mockPages = [createPage([createMockApp()])]
renderList()
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
fireEvent.click(checkbox)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
// -- Fetching next page skeleton --
describe('Pagination Loading', () => {
it('should show skeleton when fetching next page', () => {
mockPages = [createPage([createMockApp()])]
mockIsFetchingNextPage = true
renderList()
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
})
})
// -- Dataset operator redirect --
describe('Dataset Operator Redirect', () => {
it('should redirect dataset operators to /datasets', () => {
mockIsCurrentWorkspaceDatasetOperator = true
renderList()
expect(mockRouterReplace).toHaveBeenCalledWith('/datasets')
})
})
// -- Multiple pages of data --
describe('Multi-page Data', () => {
it('should render apps from multiple pages', () => {
mockPages = [
createPage([
createMockApp({ id: 'app-1', name: 'Page One App' }),
], true, 1),
createPage([
createMockApp({ id: 'app-2', name: 'Page Two App' }),
], false, 2),
]
renderList()
expect(screen.getByText('Page One App')).toBeInTheDocument()
expect(screen.getByText('Page Two App')).toBeInTheDocument()
})
})
// -- controlRefreshList triggers refetch --
describe('Refresh List', () => {
it('should call refetch when controlRefreshList increments', () => {
mockPages = [createPage([createMockApp()])]
const { rerender } = render(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
rerender(
<NuqsTestingAdapter>
<List controlRefreshList={1} />
</NuqsTestingAdapter>,
)
expect(mockRefetch).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,465 @@
/**
* Integration test: Create App Flow
*
* Tests the end-to-end user flows for creating new apps:
* - Creating from blank via NewAppCard
* - Creating from template via NewAppCard
* - Creating from DSL import via NewAppCard
* - Apps page top-level state management
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
let mockIsCurrentWorkspaceEditor = true
let mockIsCurrentWorkspaceDatasetOperator = false
let mockIsLoadingCurrentWorkspace = false
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
let mockPages: AppListResponse[] = []
let mockIsLoading = false
let mockIsFetching = false
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
let mockShowTagManagementModal = false
const mockRouterPush = vi.fn()
const mockRouterReplace = vi.fn()
const mockOnPlanInfoChanged = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator,
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = { systemFeatures: mockSystemFeatures }
return selector ? selector(state) : state
},
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
tagList: [],
showTagManagementModal: mockShowTagManagementModal,
setTagList: vi.fn(),
setShowTagManagementModal: vi.fn(),
}
return selector(state)
},
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: (...args: unknown[]) => fnRef.current(...args),
}
},
}
})
// Mock dynamically loaded modals with test stubs
vi.mock('next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
loader().then((mod) => {
Component = mod.default as React.ComponentType<Record<string, unknown>>
}).catch(() => {})
const Wrapper = (props: Record<string, unknown>) => {
if (Component)
return <Component {...props} />
return null
}
Wrapper.displayName = 'DynamicWrapper'
return Wrapper
},
}))
vi.mock('@/app/components/app/create-app-modal', () => ({
default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => {
if (!show)
return null
return (
<div data-testid="create-app-modal">
<button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>Create Blank</button>
{!!onCreateFromTemplate && (
<button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>From Template</button>
)}
<button data-testid="create-blank-cancel" onClick={onClose as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-app-dialog', () => ({
default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => {
if (!show)
return null
return (
<div data-testid="template-dialog">
<button data-testid="template-confirm" onClick={onSuccess as () => void}>Create from Template</button>
{!!onCreateFromBlank && (
<button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>From Blank</button>
)}
<button data-testid="template-cancel" onClick={onClose as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
if (!show)
return null
return (
<div data-testid="create-from-dsl-modal">
<button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>Import DSL</button>
<button data-testid="dsl-import-cancel" onClick={onClose as () => void}>Cancel</button>
</div>
)
},
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
FROM_FILE: 'from-file',
},
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'Test App',
description: overrides.description ?? 'A test app',
author_name: overrides.author_name ?? 'Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? {} as App['model_config'],
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? {} as App['site'],
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
})
const createPage = (apps: App[]): AppListResponse => ({
data: apps,
has_more: false,
limit: 30,
page: 1,
total: apps.length,
})
const renderList = () => {
return render(
<NuqsTestingAdapter>
<List controlRefreshList={0} />
</NuqsTestingAdapter>,
)
}
describe('Create App Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceEditor = true
mockIsCurrentWorkspaceDatasetOperator = false
mockIsLoadingCurrentWorkspace = false
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
mockPages = [createPage([createMockApp()])]
mockIsLoading = false
mockIsFetching = false
mockShowTagManagementModal = false
})
// -- NewAppCard rendering --
describe('NewAppCard Rendering', () => {
it('should render the "Create App" card with all options', () => {
renderList()
expect(screen.getByText('app.createApp')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
})
it('should not render NewAppCard when user is not an editor', () => {
mockIsCurrentWorkspaceEditor = false
renderList()
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
})
it('should show loading state when workspace is loading', () => {
mockIsLoadingCurrentWorkspace = true
renderList()
// NewAppCard renders but with loading style (pointer-events-none opacity-50)
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
})
// -- Create from blank --
describe('Create from Blank Flow', () => {
it('should open the create app modal when "Start from Blank" is clicked', async () => {
renderList()
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should close the create app modal on cancel', async () => {
renderList()
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('create-blank-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
it('should call onPlanInfoChanged and refetch on successful creation', async () => {
renderList()
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('create-blank-confirm'))
await waitFor(() => {
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockRefetch).toHaveBeenCalled()
})
})
})
// -- Create from template --
describe('Create from Template Flow', () => {
it('should open template dialog when "Start from Template" is clicked', async () => {
renderList()
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
await waitFor(() => {
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
})
})
it('should allow switching from template to blank modal', async () => {
renderList()
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
await waitFor(() => {
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('switch-to-blank'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument()
})
})
it('should allow switching from blank to template dialog', async () => {
renderList()
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('switch-to-template'))
await waitFor(() => {
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
})
// -- Create from DSL import (via NewAppCard button) --
describe('Create from DSL Import Flow', () => {
it('should open DSL import modal when "Import DSL" is clicked', async () => {
renderList()
fireEvent.click(screen.getByText('app.importDSL'))
await waitFor(() => {
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
})
})
it('should close DSL import modal on cancel', async () => {
renderList()
fireEvent.click(screen.getByText('app.importDSL'))
await waitFor(() => {
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dsl-import-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument()
})
})
it('should call onPlanInfoChanged and refetch on successful DSL import', async () => {
renderList()
fireEvent.click(screen.getByText('app.importDSL'))
await waitFor(() => {
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dsl-import-confirm'))
await waitFor(() => {
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockRefetch).toHaveBeenCalled()
})
})
})
// -- DSL drag-and-drop flow (via List component) --
describe('DSL Drag-Drop Flow', () => {
it('should show drag-drop hint in the list', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should open create-from-DSL modal when DSL file is dropped', async () => {
const { act } = await import('@testing-library/react')
renderList()
const container = document.querySelector('[class*="overflow-y-auto"]')
if (container) {
const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' })
// Simulate the full drag-drop sequence wrapped in act
await act(async () => {
const dragEnterEvent = new Event('dragenter', { bubbles: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'], files: [] },
})
Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() })
Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() })
container.dispatchEvent(dragEnterEvent)
const dropEvent = new Event('drop', { bubbles: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [yamlFile], types: ['Files'] },
})
Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() })
Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() })
container.dispatchEvent(dropEvent)
})
await waitFor(() => {
const modal = screen.queryByTestId('create-from-dsl-modal')
if (modal)
expect(modal).toBeInTheDocument()
})
}
})
})
// -- Edge cases --
describe('Edge Cases', () => {
it('should not show create options when no data and user is editor', () => {
mockPages = [createPage([])]
renderList()
// NewAppCard should still be visible even with no apps
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
it('should handle multiple rapid clicks on create buttons without crashing', async () => {
renderList()
// Rapidly click different create options
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByText('app.importDSL'))
// Should not crash, and some modal should be present
await waitFor(() => {
const anyModal = screen.queryByTestId('create-app-modal')
|| screen.queryByTestId('template-dialog')
|| screen.queryByTestId('create-from-dsl-modal')
expect(anyModal).toBeTruthy()
})
})
})
})

View File

@@ -1,16 +1,13 @@
import type { Mock } from 'vitest'
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { AccessMode } from '@/models/access-control'
// Mock API services - import for direct manipulation
import * as appsService from '@/service/apps'
import * as exploreService from '@/service/explore'
import * as workflowService from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
// Import component after mocks
import AppCard from './app-card'
import AppCard from '../app-card'
// Mock next/navigation
const mockPush = vi.fn()
@@ -24,11 +21,11 @@ vi.mock('next/navigation', () => ({
// Include createContext for components that use it (like Toast)
const mockNotify = vi.fn()
vi.mock('use-context-selector', () => ({
createContext: (defaultValue: any) => React.createContext(defaultValue),
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
useContext: () => ({
notify: mockNotify,
}),
useContextSelector: (_context: any, selector: any) => selector({
useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => unknown) => selector({
notify: mockNotify,
}),
}))
@@ -51,7 +48,7 @@ vi.mock('@/context/provider-context', () => ({
// Mock global public store - allow dynamic configuration
let mockWebappAuthEnabled = false
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (s: any) => any) => selector({
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
webapp_auth: { enabled: mockWebappAuthEnabled },
branding: { enabled: false },
@@ -106,11 +103,11 @@ vi.mock('@/utils/time', () => ({
// Mock dynamic imports
vi.mock('next/dynamic', () => ({
default: (importFn: () => Promise<any>) => {
default: (importFn: () => Promise<unknown>) => {
const fnString = importFn.toString()
if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) {
return function MockEditAppModal({ show, onHide, onConfirm }: any) {
return function MockEditAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', {
@@ -128,7 +125,7 @@ vi.mock('next/dynamic', () => ({
}
}
if (fnString.includes('duplicate-modal')) {
return function MockDuplicateAppModal({ show, onHide, onConfirm }: any) {
return function MockDuplicateAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', {
@@ -143,26 +140,26 @@ vi.mock('next/dynamic', () => ({
}
}
if (fnString.includes('switch-app-modal')) {
return function MockSwitchAppModal({ show, onClose, onSuccess }: any) {
return function MockSwitchAppModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch'))
}
}
if (fnString.includes('base/confirm')) {
return function MockConfirm({ isShow, onCancel, onConfirm }: any) {
return function MockConfirm({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) {
if (!isShow)
return null
return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm'))
}
}
if (fnString.includes('dsl-export-confirm-modal')) {
return function MockDSLExportModal({ onClose, onConfirm }: any) {
return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) {
return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets'))
}
}
if (fnString.includes('app-access-control')) {
return function MockAccessControl({ onClose, onConfirm }: any) {
return function MockAccessControl({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) {
return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm'))
}
}
@@ -172,7 +169,9 @@ vi.mock('next/dynamic', () => ({
// Popover uses @headlessui/react portals - mock for controlled interaction testing
vi.mock('@/app/components/base/popover', () => {
const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => {
type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode)
type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) }
const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => {
const [isOpen, setIsOpen] = React.useState(false)
const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : ''
return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', {
@@ -188,13 +187,13 @@ vi.mock('@/app/components/base/popover', () => {
// Tooltip uses portals - minimal mock preserving popup content as title attribute
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children),
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children),
}))
// TagSelector has API dependency (service/tag) - mock for isolated testing
vi.mock('@/app/components/base/tag-management/selector', () => ({
default: ({ tags }: any) => {
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: any) => React.createElement('span', { key: tag.id }, tag.name)))
default: ({ tags }: { tags?: { id: string, name: string }[] }) => {
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name)))
},
}))
@@ -203,11 +202,7 @@ vi.mock('@/app/components/app/type-selector', () => ({
AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockApp = (overrides: Record<string, any> = {}) => ({
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
@@ -229,16 +224,8 @@ const createMockApp = (overrides: Record<string, any> = {}) => ({
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as any,
app_model_config: {} as any,
site: {} as any,
api_base_url: 'https://api.example.com',
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
} as App)
describe('AppCard', () => {
const mockApp = createMockApp()
@@ -1171,7 +1158,7 @@ describe('AppCard', () => {
(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error'))
// Configure mockOpenAsyncWindow to call the callback and trigger error
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => {
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
try {
await callback()
}
@@ -1213,7 +1200,7 @@ describe('AppCard', () => {
(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] })
// Configure mockOpenAsyncWindow to call the callback and trigger error
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => {
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
try {
await callback()
}

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from './empty'
import Empty from '../empty'
describe('Empty', () => {
beforeEach(() => {
@@ -21,7 +21,6 @@ describe('Empty', () => {
it('should display the no apps found message', () => {
render(<Empty />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Footer from './footer'
import Footer from '../footer'
describe('Footer', () => {
beforeEach(() => {
@@ -15,7 +15,6 @@ describe('Footer', () => {
it('should display the community heading', () => {
render(<Footer />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.join')).toBeInTheDocument()
})

View File

@@ -3,21 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
// Import after mocks
import Apps from './index'
import Apps from '../index'
// Track mock calls
let documentTitleCalls: string[] = []
let educationInitCalls: number = 0
// Mock useDocumentTitle hook
vi.mock('@/hooks/use-document-title', () => ({
default: (title: string) => {
documentTitleCalls.push(title)
},
}))
// Mock useEducationInit hook
vi.mock('@/app/education-apply/hooks', () => ({
useEducationInit: () => {
educationInitCalls++
@@ -33,8 +29,7 @@ vi.mock('@/hooks/use-import-dsl', () => ({
}),
}))
// Mock List component
vi.mock('./list', () => ({
vi.mock('../list', () => ({
default: () => {
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
},
@@ -100,10 +95,7 @@ describe('Apps', () => {
it('should render full component tree', () => {
renderWithClient(<Apps />)
// Verify container exists
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
// Verify hooks were called
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
})

View File

@@ -1,12 +1,13 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { AppModeEnum } from '@/types/app'
// Import after mocks
import List from './list'
import List from '../list'
// Mock next/navigation
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('next/navigation', () => ({
@@ -14,7 +15,6 @@ vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
}))
// Mock app context
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
@@ -24,7 +24,6 @@ vi.mock('@/context/app-context', () => ({
}),
}))
// Mock global public store
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
@@ -33,41 +32,28 @@ vi.mock('@/context/global-public-context', () => ({
}),
}))
// Mock custom hooks - allow dynamic query state
const mockSetQuery = vi.fn()
const mockQueryState = {
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
vi.mock('./hooks/use-apps-query-state', () => ({
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
setQuery: mockSetQuery,
}),
}))
// Store callback for testing DSL file drop
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('./hooks/use-dsl-drag-drop', () => ({
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
return { dragging: mockDragging }
},
}))
const mockSetActiveTab = vi.fn()
vi.mock('nuqs', () => ({
useQueryState: () => ['all', mockSetActiveTab],
parseAsString: {
withDefault: () => ({
withOptions: () => ({}),
}),
},
}))
// Mock service hooks - use object for mutable state (vi.mock is hoisted)
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
@@ -124,47 +110,20 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
// Use real tag store - global zustand mock will auto-reset between tests
// Mock tag service to avoid API calls in TagFilter
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
// Store TagFilter onChange callback for testing
let mockTagFilterOnChange: ((value: string[]) => void) | null = null
vi.mock('@/app/components/base/tag-management/filter', () => ({
default: ({ onChange }: { onChange: (value: string[]) => void }) => {
mockTagFilterOnChange = onChange
return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder')
},
}))
// Mock config
vi.mock('@/config', () => ({
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
}))
// Mock pay hook
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
// Mock ahooks - useMount only executes once on mount, not on fn change
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => ({ run: fn }),
useMount: (fn: () => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
React.useEffect(() => {
fnRef.current()
}, [])
},
}))
// Mock dynamic imports
vi.mock('next/dynamic', () => ({
default: (importFn: () => Promise<any>) => {
default: (importFn: () => Promise<unknown>) => {
const fnString = importFn.toString()
if (fnString.includes('tag-management')) {
@@ -173,7 +132,7 @@ vi.mock('next/dynamic', () => ({
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
@@ -183,41 +142,34 @@ vi.mock('next/dynamic', () => ({
},
}))
/**
* Mock child components for focused List component testing.
* These mocks isolate the List component's behavior from its children.
* Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests.
*/
vi.mock('./app-card', () => ({
default: ({ app }: any) => {
vi.mock('../app-card', () => ({
default: ({ app }: { app: { id: string, name: string } }) => {
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
},
}))
vi.mock('./new-app-card', () => ({
default: React.forwardRef((_props: any, _ref: any) => {
vi.mock('../new-app-card', () => ({
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
}))
vi.mock('./empty', () => ({
vi.mock('../empty', () => ({
default: () => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
},
}))
vi.mock('./footer', () => ({
vi.mock('../footer', () => ({
default: () => {
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
},
}))
// Store IntersectionObserver callback
let intersectionCallback: IntersectionObserverCallback | null = null
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
// Mock IntersectionObserver
beforeAll(() => {
globalThis.IntersectionObserver = class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
@@ -234,10 +186,21 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with NuqsTestingAdapter
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const renderList = (searchParams = '') => {
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
return render(<List />, { wrapper })
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
// Set up tag store state
onUrlUpdate.mockClear()
useTagStore.setState({
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
showTagManagementModal: false,
@@ -246,7 +209,6 @@ describe('List', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockTagFilterOnChange = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
@@ -260,13 +222,12 @@ describe('List', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
// Tab slider renders app type tabs
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
render(<List />)
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
@@ -277,71 +238,74 @@ describe('List', () => {
})
it('should render search input', () => {
render(<List />)
// Input component renders a searchbox
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
// Tag filter renders with placeholder text
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
render(<List />)
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
render(<List />)
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
render(<List />)
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
render(<List />)
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
render(<List />)
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should call setActiveTab when tab is clicked', () => {
render(<List />)
it('should update URL when workflow tab is clicked', async () => {
renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should call setActiveTab for all tab', () => {
render(<List />)
it('should update URL when all tab is clicked', async () => {
renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
render(<List />)
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle search input change', () => {
render(<List />)
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
@@ -349,55 +313,36 @@ describe('List', () => {
expect(mockSetQuery).toHaveBeenCalled()
})
it('should handle search input interaction', () => {
render(<List />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle search clear button click', () => {
// Set initial keywords to make clear button visible
mockQueryState.keywords = 'existing search'
render(<List />)
renderList()
// Find and click clear button (Input component uses .group class for clear icon container)
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
// handleKeywordsChange should be called with empty string
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
render(<List />)
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render tag filter with placeholder', () => {
render(<List />)
// Tag filter is rendered
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
render(<List />)
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
render(<List />)
renderList()
// Checkbox component uses data-testid="checkbox-{id}"
// CheckboxWithLabel doesn't pass testId, so id is undefined
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
@@ -409,7 +354,7 @@ describe('List', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
@@ -417,7 +362,7 @@ describe('List', () => {
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
@@ -427,7 +372,7 @@ describe('List', () => {
it('should redirect dataset operators to datasets page', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
render(<List />)
renderList()
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
@@ -437,7 +382,7 @@ describe('List', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
render(<List />)
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
@@ -446,22 +391,30 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<List />)
const { rerender } = render(
<NuqsTestingAdapter>
<List />
</NuqsTestingAdapter>,
)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
rerender(
<NuqsTestingAdapter>
<List />
</NuqsTestingAdapter>,
)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
render(<List />)
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
render(<List />)
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
@@ -471,14 +424,20 @@ describe('List', () => {
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
render(<List />)
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
render(<List />)
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
@@ -488,8 +447,8 @@ describe('List', () => {
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should call setActiveTab for each app type', () => {
render(<List />)
it('should update URL for each app type tab click', async () => {
renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
@@ -499,45 +458,26 @@ describe('List', () => {
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
appTypeTexts.forEach(({ mode, text }) => {
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
})
})
})
describe('Search and Filter Integration', () => {
it('should display search input with correct attributes', () => {
render(<List />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('value', '')
})
it('should have tag filter component', () => {
render(<List />)
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should display created by me label', () => {
render(<List />)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
render(<List />)
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
render(<List />)
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
@@ -546,59 +486,27 @@ describe('List', () => {
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
render(<List />)
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Additional Coverage Tests
// --------------------------------------------------------------------------
describe('Additional Coverage', () => {
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = render(<List />)
// Component should render successfully with dragging state
expect(container).toBeInTheDocument()
})
it('should handle app mode filter in query params', () => {
render(<List />)
const workflowTab = screen.getByText('app.types.workflow')
fireEvent.click(workflowTab)
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should render new app card for editors', () => {
render(<List />)
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
render(<List />)
renderList()
// Simulate DSL file drop via the callback
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
// Modal should be shown
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
render(<List />)
renderList()
// Open modal via DSL file drop
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
@@ -607,16 +515,14 @@ describe('List', () => {
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
// Close modal
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should close DSL modal and refetch when onSuccess is called', () => {
render(<List />)
renderList()
// Open modal via DSL file drop
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
@@ -625,67 +531,18 @@ describe('List', () => {
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
// Click success button
fireEvent.click(screen.getByTestId('success-dsl-modal'))
// Modal should be closed and refetch should be called
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Tag Filter Change', () => {
it('should handle tag filter value change', () => {
vi.useFakeTimers()
render(<List />)
// TagFilter component is rendered
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
// Trigger tag filter change via captured callback
act(() => {
if (mockTagFilterOnChange)
mockTagFilterOnChange(['tag-1', 'tag-2'])
})
// Advance timers to trigger debounced setTagIDs
act(() => {
vi.advanceTimersByTime(500)
})
// setQuery should have been called with updated tagIDs
expect(mockSetQuery).toHaveBeenCalled()
vi.useRealTimers()
})
it('should handle empty tag filter selection', () => {
vi.useFakeTimers()
render(<List />)
// Trigger tag filter change with empty array
act(() => {
if (mockTagFilterOnChange)
mockTagFilterOnChange([])
})
// Advance timers
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetQuery).toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
render(<List />)
renderList()
// Simulate intersection
if (intersectionCallback) {
act(() => {
intersectionCallback!(
@@ -700,9 +557,8 @@ describe('List', () => {
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
render(<List />)
renderList()
// Simulate non-intersection
if (intersectionCallback) {
act(() => {
intersectionCallback!(
@@ -718,7 +574,7 @@ describe('List', () => {
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
render(<List />)
renderList()
if (intersectionCallback) {
act(() => {
@@ -736,11 +592,8 @@ describe('List', () => {
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = render(<List />)
// Component should still render
const { container } = renderList()
expect(container).toBeInTheDocument()
// Disconnect should be called when there's an error (cleanup)
})
})
})

View File

@@ -1,10 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
// Import after mocks
import CreateAppCard from './new-app-card'
import CreateAppCard from '../new-app-card'
// Mock next/navigation
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
@@ -13,7 +11,6 @@ vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}))
// Mock provider context
const mockOnPlanInfoChanged = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@@ -21,37 +18,35 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
// Mock next/dynamic to immediately resolve components
vi.mock('next/dynamic', () => ({
default: (importFn: () => Promise<any>) => {
default: (importFn: () => Promise<{ default: React.ComponentType }>) => {
const fnString = importFn.toString()
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: any) {
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template'))
return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate as () => void, 'data-testid': 'to-template-modal' }, 'To Template'))
}
}
if (fnString.includes('create-app-dialog')) {
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: any) {
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank'))
return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank as () => void, 'data-testid': 'to-blank-modal' }, 'To Blank'))
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: Record<string, unknown>) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-dsl-modal' }, 'Success'))
}
}
return () => null
},
}))
// Mock CreateFromDSLModalTab enum
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
@@ -68,7 +63,6 @@ describe('CreateAppCard', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard ref={defaultRef} />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
@@ -245,19 +239,15 @@ describe('CreateAppCard', () => {
it('should handle multiple modal opens/closes', () => {
render(<CreateAppCard ref={defaultRef} />)
// Open and close create modal
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('close-create-modal'))
// Open and close template dialog
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('close-template-dialog'))
// Open and close DSL modal
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('close-dsl-modal'))
// No modals should be visible
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
@@ -267,7 +257,6 @@ describe('CreateAppCard', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
// This should not throw an error
expect(() => {
fireEvent.click(screen.getByTestId('success-create-modal'))
}).not.toThrow()

View File

@@ -1,16 +1,8 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
/**
* Test suite for useAppsQueryState hook
*
* This hook manages app filtering state through URL search parameters, enabling:
* - Bookmarkable filter states (users can share URLs with specific filters active)
* - Browser history integration (back/forward buttons work with filters)
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
*/
import { act, renderHook, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import useAppsQueryState from './use-apps-query-state'
import useAppsQueryState from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
@@ -23,13 +15,11 @@ const renderWithAdapter = (searchParams = '') => {
return { result, onUrlUpdate }
}
// Groups scenarios for useAppsQueryState behavior.
describe('useAppsQueryState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers the hook return shape and default values.
describe('Initialization', () => {
it('should expose query and setQuery when initialized', () => {
const { result } = renderWithAdapter()
@@ -47,7 +37,6 @@ describe('useAppsQueryState', () => {
})
})
// Covers parsing of existing URL search params.
describe('Parsing search params', () => {
it('should parse tagIDs when URL includes tagIDs', () => {
const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
@@ -78,7 +67,6 @@ describe('useAppsQueryState', () => {
})
})
// Covers updates driven by setQuery.
describe('Updating query state', () => {
it('should update keywords when setQuery receives keywords', () => {
const { result } = renderWithAdapter()
@@ -126,7 +114,6 @@ describe('useAppsQueryState', () => {
})
})
// Covers URL updates triggered by query changes.
describe('URL synchronization', () => {
it('should sync keywords to URL when keywords change', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
@@ -202,7 +189,6 @@ describe('useAppsQueryState', () => {
})
})
// Covers decoding and empty values.
describe('Edge cases', () => {
it('should treat empty tagIDs as empty list when URL param is empty', () => {
const { result } = renderWithAdapter('?tagIDs=')
@@ -223,7 +209,6 @@ describe('useAppsQueryState', () => {
})
})
// Covers multi-step updates that mimic real usage.
describe('Integration scenarios', () => {
it('should keep accumulated filters when updates are sequential', () => {
const { result } = renderWithAdapter()

View File

@@ -1,15 +1,6 @@
/**
* Test suite for useDSLDragDrop hook
*
* This hook provides drag-and-drop functionality for DSL files, enabling:
* - File drag detection with visual feedback (dragging state)
* - YAML/YML file filtering (only accepts .yaml and .yml files)
* - Enable/disable toggle for conditional drag-and-drop
* - Cleanup on unmount (removes event listeners)
*/
import type { Mock } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { useDSLDragDrop } from './use-dsl-drag-drop'
import { useDSLDragDrop } from '../use-dsl-drag-drop'
describe('useDSLDragDrop', () => {
let container: HTMLDivElement
@@ -26,7 +17,6 @@ describe('useDSLDragDrop', () => {
document.body.removeChild(container)
})
// Helper to create drag events
const createDragEvent = (type: string, files: File[] = []) => {
const dataTransfer = {
types: files.length > 0 ? ['Files'] : [],
@@ -50,7 +40,6 @@ describe('useDSLDragDrop', () => {
return event
}
// Helper to create a mock file
const createMockFile = (name: string) => {
return new File(['content'], name, { type: 'application/x-yaml' })
}
@@ -147,14 +136,12 @@ describe('useDSLDragDrop', () => {
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then leave with null relatedTarget (leaving container)
const leaveEvent = createDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', {
value: null,
@@ -180,14 +167,12 @@ describe('useDSLDragDrop', () => {
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then leave but to a child element
const leaveEvent = createDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', {
value: childElement,
@@ -290,14 +275,12 @@ describe('useDSLDragDrop', () => {
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then drop
const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(dropEvent)
@@ -409,14 +392,12 @@ describe('useDSLDragDrop', () => {
{ initialProps: { enabled: true } },
)
// Set dragging state
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Disable the hook
rerender({ enabled: false })
expect(result.current.dragging).toBe(false)
})

View File

@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ContentDialog from './index'
describe('ContentDialog', () => {
it('renders children when show is true', async () => {
render(
<ContentDialog show={true}>
<div>Dialog body</div>
</ContentDialog>,
)
await screen.findByText('Dialog body')
expect(screen.getByText('Dialog body')).toBeInTheDocument()
const backdrop = document.querySelector('.bg-app-detail-overlay-bg')
expect(backdrop).toBeTruthy()
})
it('does not render children when show is false', () => {
render(
<ContentDialog show={false}>
<div>Hidden content</div>
</ContentDialog>,
)
expect(screen.queryByText('Hidden content')).toBeNull()
expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull()
})
it('calls onClose when backdrop is clicked', async () => {
const onClose = vi.fn()
render(
<ContentDialog show={true} onClose={onClose}>
<div>Body</div>
</ContentDialog>,
)
const user = userEvent.setup()
const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null
expect(backdrop).toBeTruthy()
await user.click(backdrop!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('applies provided className to the content panel', () => {
render(
<ContentDialog show={true} className="my-panel-class">
<div>Panel content</div>
</ContentDialog>,
)
const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null
expect(contentPanel).toBeTruthy()
expect(contentPanel?.className).toContain('my-panel-class')
expect(screen.getByText('Panel content')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,138 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import CustomDialog from './index'
describe('CustomDialog Component', () => {
const setup = () => userEvent.setup()
it('should render children and title when show is true', async () => {
render(
<CustomDialog show={true} title="Modal Title">
<div data-testid="dialog-content">Main Content</div>
</CustomDialog>,
)
const title = await screen.findByText('Modal Title')
const content = screen.getByTestId('dialog-content')
expect(title).toBeInTheDocument()
expect(content).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should not render anything when show is false', async () => {
render(
<CustomDialog show={false} title="Hidden Title">
<div>Content</div>
</CustomDialog>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument()
})
it('should apply the correct semantic tag to title using titleAs', async () => {
render(
<CustomDialog show={true} title="Semantic Title" titleAs="h1">
Content
</CustomDialog>,
)
const title = await screen.findByRole('heading', { level: 1 })
expect(title).toHaveTextContent('Semantic Title')
})
it('should render the footer only when the prop is provided', async () => {
const { rerender } = render(
<CustomDialog show={true}>Content</CustomDialog>,
)
await screen.findByRole('dialog')
expect(screen.queryByText('Footer Content')).not.toBeInTheDocument()
rerender(
<CustomDialog show={true} footer={<div data-testid="footer-node">Footer Content</div>}>
Content
</CustomDialog>,
)
expect(await screen.findByTestId('footer-node')).toBeInTheDocument()
})
it('should call onClose when Escape key is pressed', async () => {
const user = setup()
const onCloseMock = vi.fn()
render(
<CustomDialog show={true} onClose={onCloseMock}>
Content
</CustomDialog>,
)
await screen.findByRole('dialog')
await act(async () => {
await user.keyboard('{Escape}')
})
expect(onCloseMock).toHaveBeenCalledTimes(1)
})
it('should call onClose when the backdrop is clicked', async () => {
const user = setup()
const onCloseMock = vi.fn()
render(
<CustomDialog show={true} onClose={onCloseMock}>
Content
</CustomDialog>,
)
await screen.findByRole('dialog')
const backdrop = document.querySelector('.bg-background-overlay-backdrop')
expect(backdrop).toBeInTheDocument()
await act(async () => {
await user.click(backdrop!)
})
expect(onCloseMock).toHaveBeenCalledTimes(1)
})
it('should apply custom class names to internal elements', async () => {
render(
<CustomDialog
show={true}
title="Title"
className="custom-panel-container"
titleClassName="custom-title-style"
bodyClassName="custom-body-style"
footer="Footer"
footerClassName="custom-footer-style"
>
<div data-testid="content">Content</div>
</CustomDialog>,
)
await screen.findByRole('dialog')
expect(document.querySelector('.custom-panel-container')).toBeInTheDocument()
expect(document.querySelector('.custom-title-style')).toBeInTheDocument()
expect(document.querySelector('.custom-body-style')).toBeInTheDocument()
expect(document.querySelector('.custom-footer-style')).toBeInTheDocument()
})
it('should maintain accessibility attributes (aria-modal)', async () => {
render(
<CustomDialog show={true} title="Accessibility Test">
<button>Focusable Item</button>
</CustomDialog>,
)
const dialog = await screen.findByRole('dialog')
// Headless UI should automatically set aria-modal="true"
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})

View File

@@ -1,11 +1,9 @@
import type {
FileEntity,
} from './types'
import { isEqual } from 'es-toolkit/predicate'
import {
createContext,
useContext,
useEffect,
useRef,
} from 'react'
import {
@@ -57,20 +55,10 @@ export const FileContextProvider = ({
onChange,
}: FileProviderProps) => {
const storeRef = useRef<FileStore | undefined>(undefined)
if (!storeRef.current)
storeRef.current = createFileStore(value, onChange)
useEffect(() => {
if (!storeRef.current)
return
if (isEqual(value, storeRef.current.getState().files))
return
storeRef.current.setState({
files: value ? [...value] : [],
})
}, [value])
return (
<FileContext.Provider value={storeRef.current}>
{children}

View File

@@ -0,0 +1,214 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import FullScreenModal from './index'
describe('FullScreenModal Component', () => {
it('should not render anything when open is false', () => {
render(
<FullScreenModal open={false}>
<div data-testid="modal-content">Content</div>
</FullScreenModal>,
)
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument()
})
it('should render content when open is true', async () => {
render(
<FullScreenModal open={true}>
<div data-testid="modal-content">Content</div>
</FullScreenModal>,
)
expect(await screen.findByTestId('modal-content')).toBeInTheDocument()
})
it('should not crash when provided with title and description props', async () => {
await act(async () => {
render(
<FullScreenModal
open={true}
title="My Title"
description="My Description"
>
Content
</FullScreenModal>,
)
})
})
describe('Props Handling', () => {
it('should apply wrapperClassName to the dialog root', async () => {
render(
<FullScreenModal
open={true}
wrapperClassName="custom-wrapper-class"
>
Content
</FullScreenModal>,
)
await screen.findByRole('dialog')
const element = document.querySelector('.custom-wrapper-class')
expect(element).toBeInTheDocument()
expect(element).toHaveClass('modal-dialog')
})
it('should apply className to the inner panel', async () => {
await act(async () => {
render(
<FullScreenModal
open={true}
className="custom-panel-class"
>
Content
</FullScreenModal>,
)
})
const panel = document.querySelector('.custom-panel-class')
expect(panel).toBeInTheDocument()
expect(panel).toHaveClass('h-full')
})
it('should handle overflowVisible prop', async () => {
const { rerender } = await act(async () => {
return render(
<FullScreenModal
open={true}
overflowVisible={true}
className="target-panel"
>
Content
</FullScreenModal>,
)
})
let panel = document.querySelector('.target-panel')
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
await act(async () => {
rerender(
<FullScreenModal
open={true}
overflowVisible={false}
className="target-panel"
>
Content
</FullScreenModal>,
)
})
panel = document.querySelector('.target-panel')
expect(panel).toHaveClass('overflow-hidden')
expect(panel).not.toHaveClass('overflow-visible')
})
it('should render close button when closable is true', async () => {
await act(async () => {
render(
<FullScreenModal open={true} closable={true}>
Content
</FullScreenModal>,
)
})
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
expect(closeButton).toBeInTheDocument()
})
it('should not render close button when closable is false', async () => {
await act(async () => {
render(
<FullScreenModal open={true} closable={false}>
Content
</FullScreenModal>,
)
})
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
expect(closeButton).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClose when close button is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} closable={true} onClose={onClose}>
Content
</FullScreenModal>,
)
const closeBtn = document.querySelector('.bg-components-button-tertiary-bg')
expect(closeBtn).toBeInTheDocument()
await user.click(closeBtn!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when clicking the backdrop', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} onClose={onClose}>
<div data-testid="inner">Content</div>
</FullScreenModal>,
)
const dialog = document.querySelector('.modal-dialog')
if (dialog) {
await user.click(dialog)
expect(onClose).toHaveBeenCalled()
}
else {
throw new Error('Dialog root not found')
}
})
it('should call onClose when Escape key is pressed', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} onClose={onClose}>
Content
</FullScreenModal>,
)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('should not call onClose when clicking inside the content', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} onClose={onClose}>
<div className="bg-background-default-subtle">
<button>Action</button>
</div>
</FullScreenModal>,
)
const innerButton = screen.getByRole('button', { name: 'Action' })
await user.click(innerButton)
expect(onClose).not.toHaveBeenCalled()
const contentPanel = document.querySelector('.bg-background-default-subtle')
await act(async () => {
fireEvent.click(contentPanel!)
})
expect(onClose).not.toHaveBeenCalled()
})
})
describe('Default Props', () => {
it('should not throw if onClose is not provided', async () => {
const user = userEvent.setup()
render(<FullScreenModal open={true} closable={true}>Content</FullScreenModal>)
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
await user.click(closeButton!)
})
})
})

View File

@@ -0,0 +1,205 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { useParams, usePathname } from 'next/navigation'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import AudioBtn from './index'
const mockPlayAudio = vi.fn()
const mockPauseAudio = vi.fn()
const mockGetAudioPlayer = vi.fn()
vi.mock('next/navigation', () => ({
useParams: vi.fn(),
usePathname: vi.fn(),
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: vi.fn(() => ({
getAudioPlayer: mockGetAudioPlayer,
})),
},
}))
describe('AudioBtn', () => {
const getButton = () => screen.getByRole('button')
const hoverAndCheckTooltip = async (expectedText: string) => {
const button = getButton()
await userEvent.hover(button)
expect(await screen.findByText(expectedText)).toBeInTheDocument()
}
const getAudioCallback = () => {
const lastCall = mockGetAudioPlayer.mock.calls[mockGetAudioPlayer.mock.calls.length - 1]
const callback = lastCall?.find((arg: unknown) => typeof arg === 'function') as ((event: string) => void) | undefined
if (!callback)
throw new Error('Audio callback not found - ensure mockGetAudioPlayer was called with a callback argument')
return callback
}
beforeAll(() => {
i18next.init({})
})
beforeEach(() => {
vi.clearAllMocks()
mockGetAudioPlayer.mockReturnValue({
playAudio: mockPlayAudio,
pauseAudio: mockPauseAudio,
})
; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({})
; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/')
})
describe('URL Routing', () => {
it('should generate public URL when token is present', async () => {
; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ token: 'test-token' })
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/text-to-audio')
expect(mockGetAudioPlayer.mock.calls[0][1]).toBe(true)
})
it('should generate app URL when appId is present', async () => {
; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '123' })
; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/apps/123/chat')
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/apps/123/text-to-audio')
expect(mockGetAudioPlayer.mock.calls[0][1]).toBe(false)
})
it('should generate installed app URL correctly', async () => {
; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '456' })
; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/explore/installed/app')
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/installed-apps/456/text-to-audio')
})
})
describe('State Management', () => {
it('should start in initial state', async () => {
render(<AudioBtn value="test" />)
await hoverAndCheckTooltip('play')
expect(getButton()).toHaveClass('action-btn')
expect(getButton()).not.toBeDisabled()
})
it('should transition to playing state', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
act(() => {
getAudioCallback()('play')
})
await hoverAndCheckTooltip('playing')
expect(getButton()).toHaveClass('action-btn-active')
})
it('should transition to ended state', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
act(() => {
getAudioCallback()('play')
})
act(() => {
getAudioCallback()('ended')
})
await hoverAndCheckTooltip('play')
expect(getButton()).not.toHaveClass('action-btn-active')
})
it('should handle paused event', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
act(() => {
getAudioCallback()('play')
})
act(() => {
getAudioCallback()('paused')
})
await hoverAndCheckTooltip('play')
})
it('should handle error event', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
act(() => {
getAudioCallback()('error')
})
await hoverAndCheckTooltip('play')
})
it('should handle loaded event', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
act(() => {
getAudioCallback()('loaded')
})
await hoverAndCheckTooltip('loading')
})
})
describe('Play/Pause', () => {
it('should call playAudio when clicked', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockPlayAudio).toHaveBeenCalled())
})
it('should call pauseAudio when clicked while playing', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
act(() => {
getAudioCallback()('play')
})
await userEvent.click(getButton())
await waitFor(() => expect(mockPauseAudio).toHaveBeenCalled())
})
it('should disable button when loading', async () => {
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await waitFor(() => expect(getButton()).toBeDisabled())
})
})
describe('Props', () => {
it('should pass props to audio player', async () => {
render(<AudioBtn value="hello" id="msg-1" voice="en-US" />)
await userEvent.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
expect(call[2]).toBe('msg-1')
expect(call[3]).toBe('hello')
expect(call[4]).toBe('en-US')
})
})
})

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import NotionConnector from './index'
describe('NotionConnector', () => {
it('should render the layout and actual sub-components (Icons & Button)', () => {
const { container } = render(<NotionConnector onSetting={vi.fn()} />)
// Verify Title & Tip translations
expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.notionSyncTip')).toBeInTheDocument()
const notionWrapper = container.querySelector('.h-12.w-12')
const dotsWrapper = container.querySelector('.system-md-semibold')
expect(notionWrapper?.querySelector('svg')).toBeInTheDocument()
expect(dotsWrapper?.querySelector('svg')).toBeInTheDocument()
const button = screen.getByRole('button', {
name: /datasetcreation.stepone.connect/i,
})
expect(button).toBeInTheDocument()
expect(button).toHaveClass('btn', 'btn-primary')
})
it('should trigger the onSetting callback when the real button is clicked', async () => {
const onSetting = vi.fn()
const user = userEvent.setup()
render(<NotionConnector onSetting={onSetting} />)
const button = screen.getByRole('button', {
name: /datasetcreation.stepone.connect/i,
})
await user.click(button)
expect(onSetting).toHaveBeenCalledTimes(1)
})
it('should maintain the correct visual hierarchy classes', () => {
const { container } = render(<NotionConnector onSetting={vi.fn()} />)
// Verify the outer container has the specific workflow-process-bg
const mainContainer = container.firstChild
expect(mainContainer).toHaveClass('bg-workflow-process-bg', 'rounded-2xl', 'p-6')
})
})

View File

@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import {
SkeletonContainer,
SkeletonPoint,
SkeletonRectangle,
SkeletonRow,
} from './index'
describe('Skeleton Components', () => {
describe('Individual Components', () => {
it('should forward attributes and render children in SkeletonContainer', () => {
render(
<SkeletonContainer data-testid="container" className="custom-container">
<span>Content</span>
</SkeletonContainer>,
)
const element = screen.getByTestId('container')
expect(element).toHaveClass('flex', 'flex-col', 'custom-container')
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should forward attributes and render children in SkeletonRow', () => {
render(
<SkeletonRow data-testid="row" className="custom-row">
<span>Row Content</span>
</SkeletonRow>,
)
const element = screen.getByTestId('row')
expect(element).toHaveClass('flex', 'items-center', 'custom-row')
expect(screen.getByText('Row Content')).toBeInTheDocument()
})
it('should apply base skeleton styles to SkeletonRectangle', () => {
render(<SkeletonRectangle data-testid="rect" className="w-10" />)
const element = screen.getByTestId('rect')
expect(element).toHaveClass('h-2', 'bg-text-quaternary', 'opacity-20', 'w-10')
})
it('should render the separator character correctly in SkeletonPoint', () => {
render(<SkeletonPoint data-testid="point" />)
const element = screen.getByTestId('point')
expect(element).toHaveTextContent('·')
expect(element).toHaveClass('text-text-quaternary')
})
})
describe('Composition & Layout', () => {
it('should render a full skeleton structure accurately', () => {
const { container } = render(
<SkeletonContainer className="main-wrapper">
<SkeletonRow>
<SkeletonRectangle className="rect-1" />
<SkeletonPoint />
<SkeletonRectangle className="rect-2" />
</SkeletonRow>
</SkeletonContainer>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('main-wrapper')
expect(container.querySelector('.rect-1')).toBeInTheDocument()
expect(container.querySelector('.rect-2')).toBeInTheDocument()
const row = container.querySelector('.flex.items-center')
expect(row).toContainElement(container.querySelector('.rect-1') as HTMLElement)
expect(row).toHaveTextContent('·')
})
})
it('should handle rest props like event listeners', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<SkeletonRectangle onClick={onClick} data-testid="clickable" />)
const element = screen.getByTestId('clickable')
await user.click(element)
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,77 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Slider from './index'
describe('Slider Component', () => {
it('should render with correct default ARIA limits and current value', () => {
render(<Slider value={50} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should apply custom min, max, and step values', () => {
render(<Slider value={10} min={5} max={20} step={5} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '5')
expect(slider).toHaveAttribute('aria-valuemax', '20')
expect(slider).toHaveAttribute('aria-valuenow', '10')
})
it('should default to 0 if the value prop is NaN', () => {
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuenow', '0')
})
it('should call onChange when arrow keys are pressed', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Slider value={20} onChange={onChange} />)
const slider = screen.getByRole('slider')
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(21, 0)
})
it('should not trigger onChange when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Slider value={20} onChange={onChange} disabled />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-disabled', 'true')
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onChange).not.toHaveBeenCalled()
})
it('should apply custom class names', () => {
render(
<Slider value={10} onChange={vi.fn()} className="outer-test" thumbClassName="thumb-test" />,
)
const sliderWrapper = screen.getByRole('slider').closest('.outer-test')
expect(sliderWrapper).toBeInTheDocument()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveClass('thumb-test')
})
})

View File

@@ -0,0 +1,141 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import Sort from './index'
const mockItems = [
{ value: 'created_at', name: 'Date Created' },
{ value: 'name', name: 'Name' },
{ value: 'status', name: 'Status' },
]
describe('Sort component — real portal integration', () => {
const setup = (props = {}) => {
const onSelect = vi.fn()
const user = userEvent.setup()
const { container, rerender } = render(
<Sort value="created_at" items={mockItems} onSelect={onSelect} order="" {...props} />,
)
// helper: returns a non-null HTMLElement or throws with a clear message
const getTriggerWrapper = (): HTMLElement => {
const labelNode = screen.getByText('appLog.filter.sortBy')
// try to find a reasonable wrapper element; prefer '.block' but fallback to any ancestor div
const wrapper = labelNode.closest('.block') ?? labelNode.closest('div')
if (!wrapper)
throw new Error('Trigger wrapper element not found for "Sort by" label')
return wrapper as HTMLElement
}
// helper: returns right-side sort button element
const getSortButton = (): HTMLElement => {
const btn = container.querySelector('.rounded-r-lg')
if (!btn)
throw new Error('Sort button (rounded-r-lg) not found in rendered container')
return btn as HTMLElement
}
return { user, onSelect, rerender, getTriggerWrapper, getSortButton }
}
it('renders and shows selected item label and sort icon', () => {
const { getSortButton } = setup({ order: '' })
expect(screen.getByText('Date Created')).toBeInTheDocument()
const sortButton = getSortButton()
expect(sortButton).toBeInstanceOf(HTMLElement)
expect(sortButton.querySelector('svg')).toBeInTheDocument()
})
it('opens and closes the tooltip (portal mounts to document.body)', async () => {
const { user, getTriggerWrapper } = setup()
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
expect(tooltip).toBeInTheDocument()
expect(document.body.contains(tooltip)).toBe(true)
// clicking the trigger again should close it
await user.click(getTriggerWrapper())
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
})
it('renders options and calls onSelect with descending prefix when order is "-"', async () => {
const { user, onSelect, getTriggerWrapper } = setup({ order: '-' })
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
mockItems.forEach((item) => {
expect(within(tooltip).getByText(item.name)).toBeInTheDocument()
})
await user.click(within(tooltip).getByText('Name'))
expect(onSelect).toHaveBeenCalledWith('-name')
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
})
it('toggles sorting order: ascending -> descending via right-side button', async () => {
const { user, onSelect, getSortButton } = setup({ order: '', value: 'created_at' })
await user.click(getSortButton())
expect(onSelect).toHaveBeenCalledWith('-created_at')
})
it('toggles sorting order: descending -> ascending via right-side button', async () => {
const { user, onSelect, getSortButton } = setup({ order: '-', value: 'name' })
await user.click(getSortButton())
expect(onSelect).toHaveBeenCalledWith('name')
})
it('shows checkmark only for selected item in menu', async () => {
const { user, getTriggerWrapper } = setup({ value: 'status' })
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
const statusRow = within(tooltip).getByText('Status').closest('.flex')
const nameRow = within(tooltip).getByText('Name').closest('.flex')
if (!statusRow)
throw new Error('Status option row not found in menu')
if (!nameRow)
throw new Error('Name option row not found in menu')
expect(statusRow.querySelector('svg')).toBeInTheDocument()
expect(nameRow.querySelector('svg')).not.toBeInTheDocument()
})
it('shows empty selection label when value is unknown', () => {
setup({ value: 'unknown_value' })
const label = screen.getByText('appLog.filter.sortBy')
const valueNode = label.nextSibling
if (!valueNode)
throw new Error('Expected a sibling node for the selection text')
expect(String(valueNode.textContent || '').trim()).toBe('')
})
it('handles undefined order prop without asserting a literal "undefined" prefix', async () => {
const { user, onSelect, getTriggerWrapper } = setup({ order: undefined })
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
await user.click(within(tooltip).getByText('Name'))
expect(onSelect).toHaveBeenCalled()
expect(onSelect).toHaveBeenCalledWith(expect.stringMatching(/name$/))
})
it('clicking outside the open menu closes the portal', async () => {
const { user, getTriggerWrapper } = setup()
await user.click(getTriggerWrapper())
const tooltip = await screen.findByRole('tooltip')
expect(tooltip).toBeInTheDocument()
// click outside: body click should close the tooltip
await user.click(document.body)
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
})
})

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Switch from './index'
describe('Switch', () => {
it('should render in unchecked state by default', () => {
render(<Switch />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toBeInTheDocument()
expect(switchElement).toHaveAttribute('aria-checked', 'false')
})
it('should render in checked state when defaultValue is true', () => {
render(<Switch defaultValue={true} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should toggle state and call onChange when clicked', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Switch onChange={onChange} />)
const switchElement = screen.getByRole('switch')
await user.click(switchElement)
expect(switchElement).toHaveAttribute('aria-checked', 'true')
expect(onChange).toHaveBeenCalledWith(true)
expect(onChange).toHaveBeenCalledTimes(1)
await user.click(switchElement)
expect(switchElement).toHaveAttribute('aria-checked', 'false')
expect(onChange).toHaveBeenCalledWith(false)
expect(onChange).toHaveBeenCalledTimes(2)
})
it('should not call onChange when disabled', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Switch disabled onChange={onChange} />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50')
await user.click(switchElement)
expect(onChange).not.toHaveBeenCalled()
})
it('should apply correct size classes', () => {
const { rerender } = render(<Switch size="xs" />)
// We only need to find the element once
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-sm')
rerender(<Switch size="sm" />)
expect(switchElement).toHaveClass('h-3', 'w-5')
rerender(<Switch size="md" />)
expect(switchElement).toHaveClass('h-4', 'w-7')
rerender(<Switch size="l" />)
expect(switchElement).toHaveClass('h-5', 'w-9')
rerender(<Switch size="lg" />)
expect(switchElement).toHaveClass('h-6', 'w-11')
})
it('should apply custom className', () => {
render(<Switch className="custom-test-class" />)
expect(screen.getByRole('switch')).toHaveClass('custom-test-class')
})
it('should apply correct background colors based on state', async () => {
const user = userEvent.setup()
render(<Switch />)
const switchElement = screen.getByRole('switch')
expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked')
await user.click(switchElement)
expect(switchElement).toHaveClass('bg-components-toggle-bg')
})
})

View File

@@ -0,0 +1,104 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Tag from './index'
import '@testing-library/jest-dom/vitest'
describe('Tag Component', () => {
describe('Rendering', () => {
it('should render with text children', () => {
const { container } = render(<Tag>Hello World</Tag>)
expect(container.firstChild).toHaveTextContent('Hello World')
})
it('should render with ReactNode children', () => {
render(<Tag><span data-testid="child">Node</span></Tag>)
expect(screen.getByTestId('child')).toBeInTheDocument()
})
it('should always apply base layout classes', () => {
const { container } = render(<Tag>Test</Tag>)
expect(container.firstChild).toHaveClass(
'inline-flex',
'shrink-0',
'items-center',
'rounded-md',
'px-2.5',
'py-px',
'text-xs',
'leading-5',
)
})
})
describe('Color Variants', () => {
it.each([
{ color: 'green', text: 'text-green-800', bg: 'bg-green-100' },
{ color: 'yellow', text: 'text-yellow-800', bg: 'bg-yellow-100' },
{ color: 'red', text: 'text-red-800', bg: 'bg-red-100' },
{ color: 'gray', text: 'text-gray-800', bg: 'bg-gray-100' },
])('should apply $color color classes', ({ color, text, bg }) => {
type colorType = 'green' | 'yellow' | 'red' | 'gray' | undefined
const { container } = render(<Tag color={color as colorType}>Test</Tag>)
expect(container.firstChild).toHaveClass(text, bg)
})
it('should default to green when no color specified', () => {
const { container } = render(<Tag>Test</Tag>)
expect(container.firstChild).toHaveClass('text-green-800', 'bg-green-100')
})
it('should not apply color classes for invalid color', () => {
type colorType = 'green' | 'yellow' | 'red' | 'gray' | undefined
const { container } = render(<Tag color={'invalid' as colorType}>Test</Tag>)
const className = (container.firstChild as HTMLElement)?.className || ''
expect(className).not.toMatch(/text-(green|yellow|red|gray)-800/)
expect(className).not.toMatch(/bg-(green|yellow|red|gray)-100/)
})
})
describe('Boolean Props', () => {
it('should apply border when bordered is true', () => {
const { container } = render(<Tag bordered>Test</Tag>)
expect(container.firstChild).toHaveClass('border-[1px]')
})
it('should not apply border by default', () => {
const { container } = render(<Tag>Test</Tag>)
expect(container.firstChild).not.toHaveClass('border-[1px]')
})
it('should hide background when hideBg is true', () => {
const { container } = render(<Tag hideBg>Test</Tag>)
expect(container.firstChild).toHaveClass('bg-transparent')
})
it('should apply both bordered and hideBg together', () => {
const { container } = render(<Tag bordered hideBg>Test</Tag>)
expect(container.firstChild).toHaveClass('border-[1px]', 'bg-transparent')
})
it('should override color background with hideBg', () => {
const { container } = render(<Tag color="red" hideBg>Test</Tag>)
const tag = container.firstChild
expect(tag).toHaveClass('bg-transparent', 'text-red-800')
})
})
describe('Custom Styling', () => {
it('should merge custom className', () => {
const { container } = render(<Tag className="my-custom-class">Test</Tag>)
expect(container.firstChild).toHaveClass('my-custom-class')
})
it('should preserve base classes with custom className', () => {
const { container } = render(<Tag className="my-custom-class">Test</Tag>)
expect(container.firstChild).toHaveClass('inline-flex', 'my-custom-class')
})
it('should handle empty className prop', () => {
const { container } = render(<Tag className="">Test</Tag>)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@@ -197,61 +197,30 @@ describe('AppsFull', () => {
})
describe('Edge Cases', () => {
it('should use the success color when usage is below 50%', () => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 2 }),
total: buildUsage({ buildApps: 5 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
it('should apply distinct progress bar styling at different usage levels', () => {
const renderWithUsage = (used: number, total: number) => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: used }),
total: buildUsage({ buildApps: total }),
reset: { apiRateLimit: null, triggerEvents: null },
},
},
}))
}))
const { unmount } = render(<AppsFull loc="billing_dialog" />)
const className = screen.getByTestId('billing-progress-bar').className
unmount()
return className
}
render(<AppsFull loc="billing_dialog" />)
const normalClass = renderWithUsage(2, 10)
const warningClass = renderWithUsage(6, 10)
const errorClass = renderWithUsage(8, 10)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
})
it('should use the warning color when usage is between 50% and 80%', () => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 6 }),
total: buildUsage({ buildApps: 10 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
},
},
}))
render(<AppsFull loc="billing_dialog" />)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
})
it('should use the error color when usage is 80% or higher', () => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 8 }),
total: buildUsage({ buildApps: 10 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
},
},
}))
render(<AppsFull loc="billing_dialog" />)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
})
})
})

View File

@@ -70,7 +70,7 @@ describe('HeaderBillingBtn', () => {
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('renders team badge for team plan with correct styling', () => {
it('renders team badge for team plan', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: { type: Plan.team },
enableBilling: true,
@@ -79,9 +79,7 @@ describe('HeaderBillingBtn', () => {
render(<HeaderBillingBtn />)
const badge = screen.getByText('team').closest('div')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('bg-[#E0EAFF]')
expect(screen.getByText('team')).toBeInTheDocument()
})
it('renders nothing when plan is not fetched', () => {
@@ -111,16 +109,11 @@ describe('HeaderBillingBtn', () => {
const { rerender } = render(<HeaderBillingBtn onClick={onClick} />)
const badge = screen.getByText('pro').closest('div')
expect(badge).toHaveClass('cursor-pointer')
fireEvent.click(badge!)
const badge = screen.getByText('pro').closest('div')!
fireEvent.click(badge)
expect(onClick).toHaveBeenCalledTimes(1)
rerender(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default')
fireEvent.click(screen.getByText('pro').closest('div')!)
expect(onClick).toHaveBeenCalledTimes(1)
})

View File

@@ -47,8 +47,20 @@ describe('PlanSwitcherTab', () => {
expect(handleClick).toHaveBeenCalledWith('self')
})
it('should apply active text class when isActive is true', () => {
render(
it('should apply distinct styling when isActive is true', () => {
const { rerender } = render(
<Tab
Icon={Icon}
value="cloud"
label="Cloud"
isActive={false}
onClick={vi.fn()}
/>,
)
const inactiveClassName = screen.getByText('Cloud').className
rerender(
<Tab
Icon={Icon}
value="cloud"
@@ -58,7 +70,8 @@ describe('PlanSwitcherTab', () => {
/>,
)
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
const activeClassName = screen.getByText('Cloud').className
expect(activeClassName).not.toBe(inactiveClassName)
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
})
})

View File

@@ -7,7 +7,6 @@ describe('ProgressBar', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-test-color')
expect(bar.getAttribute('style')).toContain('width: 42%')
})
@@ -18,11 +17,10 @@ describe('ProgressBar', () => {
expect(bar.getAttribute('style')).toContain('width: 100%')
})
it('uses the default color when no color prop is provided', () => {
it('renders with default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-components-progress-bar-progress-solid')
expect(bar.getAttribute('style')).toContain('width: 20%')
})
})
@@ -31,9 +29,7 @@ describe('ProgressBar', () => {
it('should render indeterminate progress bar when indeterminate is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toBeInTheDocument()
expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should not render normal progress bar when indeterminate is true', () => {
@@ -43,20 +39,20 @@ describe('ProgressBar', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render with default width (w-[30px]) when indeterminateFull is false', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />)
it('should render with different width based on indeterminateFull prop', () => {
const { rerender } = render(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
const partialClassName = bar.className
it('should render with full width (w-full) when indeterminateFull is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />)
rerender(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
expect(bar).not.toHaveClass('w-[30px]')
const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className
expect(partialClassName).not.toBe(fullClassName)
})
})
})

View File

@@ -71,8 +71,19 @@ describe('UsageInfo', () => {
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
it('applies warning color when usage is close to the limit', () => {
render(
it('applies distinct styling when usage is close to or exceeds the limit', () => {
const { rerender } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={100}
/>,
)
const normalBarClass = screen.getByTestId('billing-progress-bar').className
rerender(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -81,12 +92,10 @@ describe('UsageInfo', () => {
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
const warningBarClass = screen.getByTestId('billing-progress-bar').className
expect(warningBarClass).not.toBe(normalBarClass)
it('applies error color when usage exceeds the limit', () => {
render(
rerender(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -95,8 +104,9 @@ describe('UsageInfo', () => {
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
const errorBarClass = screen.getByTestId('billing-progress-bar').className
expect(errorBarClass).not.toBe(normalBarClass)
expect(errorBarClass).not.toBe(warningBarClass)
})
it('does not render the icon when hideIcon is true', () => {
@@ -173,8 +183,8 @@ describe('UsageInfo', () => {
expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1)
})
it('should render full-width indeterminate bar for sandbox users below threshold', () => {
render(
it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => {
const { rerender } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -187,12 +197,9 @@ describe('UsageInfo', () => {
/>,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
})
const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
it('should render narrow indeterminate bar for non-sandbox users below threshold', () => {
render(
rerender(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -205,13 +212,13 @@ describe('UsageInfo', () => {
/>,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
expect(sandboxBarClass).not.toBe(nonSandboxBarClass)
})
})
describe('Sandbox Full Capacity', () => {
it('should render error color progress bar when sandbox usage >= threshold', () => {
it('should render determinate progress bar when sandbox usage >= threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
@@ -225,8 +232,8 @@ describe('UsageInfo', () => {
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => {
@@ -305,9 +312,7 @@ describe('UsageInfo', () => {
/>,
)
// Tooltip wrapper should contain cursor-default class
const tooltipWrapper = container.querySelector('.cursor-default')
expect(tooltipWrapper).toBeInTheDocument()
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
})
})

View File

@@ -61,11 +61,10 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render full-width indeterminate bar for sandbox users', () => {
it('should render indeterminate bar for sandbox users', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should display "< 50" format for sandbox below threshold', () => {
@@ -81,11 +80,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 50
})
it('should render error color progress bar when at full capacity', () => {
it('should render determinate progress bar when at full capacity', () => {
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display "50 / 50 MB" format when at full capacity', () => {
@@ -108,19 +107,10 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width)', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText(/< 50/)).toBeInTheDocument()
// 5 GB = 5120 MB
expect(screen.getByText('5120MB')).toBeInTheDocument()
})
})
@@ -158,14 +148,6 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width)', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
@@ -196,51 +178,24 @@ describe('VectorSpaceInfo', () => {
})
})
describe('Pro/Team Plan Warning State', () => {
it('should show warning color when Professional plan usage approaches limit (80%+)', () => {
describe('Pro/Team Plan Usage States', () => {
const renderAndGetBarClass = (usage: number) => {
mockPlanType = Plan.professional
// 5120 MB * 80% = 4096 MB
mockVectorSpaceUsage = 4100
mockVectorSpaceUsage = usage
const { unmount } = render(<VectorSpaceInfo />)
const className = screen.getByTestId('billing-progress-bar').className
unmount()
return className
}
render(<VectorSpaceInfo />)
it('should show distinct progress bar styling at different usage levels', () => {
const normalClass = renderAndGetBarClass(100)
const warningClass = renderAndGetBarClass(4100)
const errorClass = renderAndGetBarClass(5200)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
it('should show warning color when Team plan usage approaches limit (80%+)', () => {
mockPlanType = Plan.team
// 20480 MB * 80% = 16384 MB
mockVectorSpaceUsage = 16500
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
})
describe('Pro/Team Plan Error State', () => {
it('should show error color when Professional plan usage exceeds limit', () => {
mockPlanType = Plan.professional
// Exceeds 5120 MB
mockVectorSpaceUsage = 5200
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('should show error color when Team plan usage exceeds limit', () => {
mockPlanType = Plan.team
// Exceeds 20480 MB
mockVectorSpaceUsage = 21000
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
})
})
@@ -265,12 +220,10 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width) for enterprise', () => {
it('should render indeterminate bar for enterprise below threshold', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should display "< 50 / total" format when below threshold', () => {

View File

@@ -6,11 +6,8 @@ import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { contactSalesUrl } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { useModalContext } from '@/context/modal-context'
// Get the mocked functions
// const { useProviderContext } = vi.requireMock('@/context/provider-context')
// const { useModalContext } = vi.requireMock('@/context/modal-context')
import { useProviderContext } from '@/context/provider-context'
import CustomPage from './index'
import CustomPage from '../index'
// Mock external dependencies only
vi.mock('@/context/provider-context', () => ({
@@ -23,7 +20,7 @@ vi.mock('@/context/modal-context', () => ({
// Mock the complex CustomWebAppBrand component to avoid dependency issues
// This is acceptable because it has complex dependencies (fetch, APIs)
vi.mock('../custom-web-app-brand', () => ({
vi.mock('@/app/components/custom/custom-web-app-brand', () => ({
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
}))

View File

@@ -7,7 +7,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
import CustomWebAppBrand from './index'
import CustomWebAppBrand from '../index'
vi.mock('@/app/components/base/toast', () => ({
useToastContext: vi.fn(),
@@ -53,8 +53,8 @@ const renderComponent = () => render(<CustomWebAppBrand />)
describe('CustomWebAppBrand', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseToastContext.mockReturnValue({ notify: mockNotify } as any)
mockUpdateCurrentWorkspace.mockResolvedValue({} as any)
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>)
mockUseAppContext.mockReturnValue({
currentWorkspace: {
custom_config: {
@@ -64,7 +64,7 @@ describe('CustomWebAppBrand', () => {
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: true,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
mockUseProviderContext.mockReturnValue({
plan: {
type: Plan.professional,
@@ -73,14 +73,14 @@ describe('CustomWebAppBrand', () => {
reset: {},
},
enableBilling: false,
} as any)
} as unknown as ReturnType<typeof useProviderContext>)
const systemFeaturesState = {
branding: {
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
}
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState } as any) : { systemFeatures: systemFeaturesState })
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState })
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
})
@@ -94,7 +94,7 @@ describe('CustomWebAppBrand', () => {
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: false,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
const { container } = renderComponent()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
@@ -112,7 +112,7 @@ describe('CustomWebAppBrand', () => {
},
mutateCurrentWorkspace: mutateMock,
isCurrentWorkspaceManager: true,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
renderComponent()
const switchInput = screen.getByRole('switch')

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
ACCEPT_TYPES,
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
} from '../constants'
describe('image-uploader constants', () => {
// Verify accepted image types
describe('ACCEPT_TYPES', () => {
it('should include standard image formats', () => {
expect(ACCEPT_TYPES).toContain('jpg')
expect(ACCEPT_TYPES).toContain('jpeg')
expect(ACCEPT_TYPES).toContain('png')
expect(ACCEPT_TYPES).toContain('gif')
})
it('should have exactly 4 types', () => {
expect(ACCEPT_TYPES).toHaveLength(4)
})
})
// Verify numeric limits are positive
describe('Limits', () => {
it('should have a positive file size limit', () => {
expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBeGreaterThan(0)
expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBe(2)
})
it('should have a positive batch limit', () => {
expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBeGreaterThan(0)
expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBe(5)
})
it('should have a positive single chunk attachment limit', () => {
expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBeGreaterThan(0)
expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBe(10)
})
})
})

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { indexMethodIcon, retrievalIcon } from '../icons'
describe('create/icons', () => {
// Verify icon map exports have expected keys
describe('indexMethodIcon', () => {
it('should have high_quality and economical keys', () => {
expect(indexMethodIcon).toHaveProperty('high_quality')
expect(indexMethodIcon).toHaveProperty('economical')
})
it('should have truthy values for each key', () => {
expect(indexMethodIcon.high_quality).toBeTruthy()
expect(indexMethodIcon.economical).toBeTruthy()
})
})
describe('retrievalIcon', () => {
it('should have vector, fullText, and hybrid keys', () => {
expect(retrievalIcon).toHaveProperty('vector')
expect(retrievalIcon).toHaveProperty('fullText')
expect(retrievalIcon).toHaveProperty('hybrid')
})
it('should have truthy values for each key', () => {
expect(retrievalIcon.vector).toBeTruthy()
expect(retrievalIcon.fullText).toBeTruthy()
expect(retrievalIcon.hybrid).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
PROGRESS_COMPLETE,
PROGRESS_ERROR,
PROGRESS_NOT_STARTED,
} from '../constants'
describe('file-uploader constants', () => {
// Verify progress sentinel values
describe('Progress Sentinels', () => {
it('should define PROGRESS_NOT_STARTED as -1', () => {
expect(PROGRESS_NOT_STARTED).toBe(-1)
})
it('should define PROGRESS_ERROR as -2', () => {
expect(PROGRESS_ERROR).toBe(-2)
})
it('should define PROGRESS_COMPLETE as 100', () => {
expect(PROGRESS_COMPLETE).toBe(100)
})
it('should have distinct values for all sentinels', () => {
const values = [PROGRESS_NOT_STARTED, PROGRESS_ERROR, PROGRESS_COMPLETE]
expect(new Set(values).size).toBe(values.length)
})
it('should have negative values for non-progress states', () => {
expect(PROGRESS_NOT_STARTED).toBeLessThan(0)
expect(PROGRESS_ERROR).toBeLessThan(0)
})
})
})

View File

@@ -0,0 +1,240 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocumentSort } from '../document-list/hooks'
import DocumentList from '../list'
// Mock hooks used by DocumentList
const mockHandleSort = vi.fn()
const mockOnSelectAll = vi.fn()
const mockOnSelectOne = vi.fn()
const mockClearSelection = vi.fn()
const mockHandleAction = vi.fn(() => vi.fn())
const mockHandleBatchReIndex = vi.fn()
const mockHandleBatchDownload = vi.fn()
const mockShowEditModal = vi.fn()
const mockHideEditModal = vi.fn()
const mockHandleSave = vi.fn()
vi.mock('../document-list/hooks', () => ({
useDocumentSort: vi.fn(() => ({
sortField: null,
sortOrder: null,
handleSort: mockHandleSort,
sortedDocuments: [],
})),
useDocumentSelection: vi.fn(() => ({
isAllSelected: false,
isSomeSelected: false,
onSelectAll: mockOnSelectAll,
onSelectOne: mockOnSelectOne,
hasErrorDocumentsSelected: false,
downloadableSelectedIds: [],
clearSelection: mockClearSelection,
})),
useDocumentActions: vi.fn(() => ({
handleAction: mockHandleAction,
handleBatchReIndex: mockHandleBatchReIndex,
handleBatchDownload: mockHandleBatchDownload,
})),
}))
vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({
default: vi.fn(() => ({
isShowEditModal: false,
showEditModal: mockShowEditModal,
hideEditModal: mockHideEditModal,
originalList: [],
handleSave: mockHandleSave,
})),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: () => ({
doc_form: 'text_model',
}),
}))
// Mock child components that are complex
vi.mock('../document-list/components', () => ({
DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail, index: number }) => (
<tr data-testid={`doc-row-${doc.id}`}>
<td>{index + 1}</td>
<td>{doc.name}</td>
</tr>
),
renderTdValue: (val: string) => val || '-',
SortHeader: ({ field, label, onSort }: { field: string, label: string, onSort: (f: string) => void }) => (
<button data-testid={`sort-${field}`} onClick={() => onSort(field)}>{label}</button>
),
}))
vi.mock('../../detail/completed/common/batch-action', () => ({
default: ({ selectedIds, onCancel }: { selectedIds: string[], onCancel: () => void }) => (
<div data-testid="batch-action">
<span data-testid="selected-count">{selectedIds.length}</span>
<button data-testid="cancel-selection" onClick={onCancel}>Cancel</button>
</div>
),
}))
vi.mock('../../rename-modal', () => ({
default: ({ name, onClose }: { name: string, onClose: () => void }) => (
<div data-testid="rename-modal">
<span>{name}</span>
<button onClick={onClose}>Close</button>
</div>
),
}))
vi.mock('@/app/components/datasets/metadata/edit-metadata-batch/modal', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="edit-metadata-modal">
<button onClick={onHide}>Hide</button>
</div>
),
}))
function createDoc(overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail {
return {
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'Test Doc',
position: 1,
data_source_type: 'upload_file',
word_count: 100,
hit_count: 5,
indexing_status: 'completed',
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
display_status: 'available',
created_from: 'web',
created_at: 1234567890,
...overrides,
} as SimpleDocumentDetail
}
const defaultProps = {
embeddingAvailable: true,
documents: [] as SimpleDocumentDetail[],
selectedIds: [] as string[],
onSelectedIdChange: vi.fn(),
datasetId: 'ds-1',
pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() },
onUpdate: vi.fn(),
onManageMetadata: vi.fn(),
statusFilterValue: 'all',
remoteSortValue: '',
}
describe('DocumentList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify the table renders with column headers
describe('Rendering', () => {
it('should render the document table with headers', () => {
render(<DocumentList {...defaultProps} />)
expect(screen.getByText('#')).toBeInTheDocument()
expect(screen.getByTestId('sort-name')).toBeInTheDocument()
expect(screen.getByTestId('sort-word_count')).toBeInTheDocument()
expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument()
expect(screen.getByTestId('sort-created_at')).toBeInTheDocument()
})
it('should render select-all area when embeddingAvailable is true', () => {
const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={true} />)
// Checkbox component renders inside the first td
const firstTd = container.querySelector('thead td')
expect(firstTd?.textContent).toContain('#')
})
it('should still render # column when embeddingAvailable is false', () => {
const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={false} />)
const firstTd = container.querySelector('thead td')
expect(firstTd?.textContent).toContain('#')
})
it('should render document rows from sortedDocuments', () => {
const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })]
vi.mocked(useDocumentSort).mockReturnValue({
sortField: null,
sortOrder: 'desc',
handleSort: mockHandleSort,
sortedDocuments: docs,
} as unknown as ReturnType<typeof useDocumentSort>)
render(<DocumentList {...defaultProps} documents={docs} />)
expect(screen.getByTestId('doc-row-a')).toBeInTheDocument()
expect(screen.getByTestId('doc-row-b')).toBeInTheDocument()
})
})
// Verify sort headers trigger sort handler
describe('Sorting', () => {
it('should call handleSort when sort header is clicked', () => {
render(<DocumentList {...defaultProps} />)
fireEvent.click(screen.getByTestId('sort-name'))
expect(mockHandleSort).toHaveBeenCalledWith('name')
})
})
// Verify batch action bar appears when items selected
describe('Batch Actions', () => {
it('should show batch action bar when selectedIds is non-empty', () => {
render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />)
expect(screen.getByTestId('batch-action')).toBeInTheDocument()
expect(screen.getByTestId('selected-count')).toHaveTextContent('1')
})
it('should not show batch action bar when no items selected', () => {
render(<DocumentList {...defaultProps} selectedIds={[]} />)
expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument()
})
it('should call clearSelection when cancel is clicked in batch bar', () => {
render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />)
fireEvent.click(screen.getByTestId('cancel-selection'))
expect(mockClearSelection).toHaveBeenCalled()
})
})
// Verify pagination renders when total > 0
describe('Pagination', () => {
it('should not render pagination when total is 0', () => {
const { container } = render(<DocumentList {...defaultProps} />)
expect(container.querySelector('[class*="pagination"]')).not.toBeInTheDocument()
})
})
// Verify empty state
describe('Edge Cases', () => {
it('should render table with no document rows when sortedDocuments is empty', () => {
// Reset sort mock to return empty sorted list
vi.mocked(useDocumentSort).mockReturnValue({
sortField: null,
sortOrder: 'desc',
handleSort: mockHandleSort,
sortedDocuments: [],
} as unknown as ReturnType<typeof useDocumentSort>)
render(<DocumentList {...defaultProps} documents={[]} />)
expect(screen.queryByTestId(/^doc-row-/)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,167 @@
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import Toast from '@/app/components/base/toast'
import Form from '../form'
// Mock the Header component (sibling component, not a base component)
vi.mock('../header', () => ({
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
onReset: () => void
resetDisabled: boolean
onPreview: () => void
previewDisabled: boolean
}) => (
<div data-testid="form-header">
<button data-testid="reset-btn" onClick={onReset} disabled={resetDisabled}>Reset</button>
<button data-testid="preview-btn" onClick={onPreview} disabled={previewDisabled}>Preview</button>
</div>
),
}))
const schema = z.object({
name: z.string().min(1, 'Name is required'),
value: z.string().optional(),
})
const defaultConfigs: BaseConfiguration[] = [
{ variable: 'name', type: 'text-input', label: 'Name', required: true, showConditions: [] } as BaseConfiguration,
{ variable: 'value', type: 'text-input', label: 'Value', required: false, showConditions: [] } as BaseConfiguration,
]
const defaultProps = {
initialData: { name: 'test', value: '' },
configurations: defaultConfigs,
schema,
onSubmit: vi.fn(),
onPreview: vi.fn(),
ref: { current: null },
isRunning: false,
}
describe('Form (process-documents)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
// Verify basic rendering of form structure
describe('Rendering', () => {
it('should render form with header and fields', () => {
render(<Form {...defaultProps} />)
expect(screen.getByTestId('form-header')).toBeInTheDocument()
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Value')).toBeInTheDocument()
})
it('should render all configuration fields', () => {
const configs: BaseConfiguration[] = [
{ variable: 'a', type: 'text-input', label: 'A', required: false, showConditions: [] } as BaseConfiguration,
{ variable: 'b', type: 'text-input', label: 'B', required: false, showConditions: [] } as BaseConfiguration,
{ variable: 'c', type: 'text-input', label: 'C', required: false, showConditions: [] } as BaseConfiguration,
]
render(<Form {...defaultProps} configurations={configs} initialData={{ a: '', b: '', c: '' }} />)
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByText('C')).toBeInTheDocument()
})
})
// Verify form submission behavior
describe('Form Submission', () => {
it('should call onSubmit with valid data on form submit', async () => {
render(<Form {...defaultProps} />)
const form = screen.getByTestId('form-header').closest('form')!
fireEvent.submit(form)
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
})
it('should call onSubmit with valid data via imperative handle', async () => {
const ref = { current: null as { submit: () => void } | null }
render(<Form {...defaultProps} ref={ref} />)
ref.current?.submit()
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
})
})
// Verify validation shows Toast on error
describe('Validation', () => {
it('should show toast error when validation fails', async () => {
render(<Form {...defaultProps} initialData={{ name: '', value: '' }} />)
const form = screen.getByTestId('form-header').closest('form')!
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
it('should not show toast error when validation passes', async () => {
render(<Form {...defaultProps} />)
const form = screen.getByTestId('form-header').closest('form')!
fireEvent.submit(form)
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
expect(Toast.notify).not.toHaveBeenCalled()
})
})
// Verify header button states
describe('Header Controls', () => {
it('should pass isRunning to previewDisabled', () => {
render(<Form {...defaultProps} isRunning={true} />)
expect(screen.getByTestId('preview-btn')).toBeDisabled()
})
it('should call onPreview when preview button is clicked', () => {
render(<Form {...defaultProps} />)
fireEvent.click(screen.getByTestId('preview-btn'))
expect(defaultProps.onPreview).toHaveBeenCalled()
})
it('should render reset button (disabled when form is not dirty)', () => {
render(<Form {...defaultProps} />)
// Reset button is rendered but disabled since form is not dirty initially
expect(screen.getByTestId('reset-btn')).toBeInTheDocument()
expect(screen.getByTestId('reset-btn')).toBeDisabled()
})
})
// Verify edge cases
describe('Edge Cases', () => {
it('should render with empty configurations array', () => {
render(<Form {...defaultProps} configurations={[]} />)
expect(screen.getByTestId('form-header')).toBeInTheDocument()
})
it('should render with empty initialData', () => {
render(<Form {...defaultProps} initialData={{}} configurations={[]} />)
expect(screen.getByTestId('form-header')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,147 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: { text: 'Book', iconName: 'book' },
web_page: { text: 'Web Page', iconName: 'web' },
paper: { text: 'Paper', iconName: 'paper' },
social_media_post: { text: 'Social Media Post', iconName: 'social' },
personal_document: { text: 'Personal Document', iconName: 'personal' },
business_document: { text: 'Business Document', iconName: 'business' },
wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' },
}),
}))
vi.mock('@/models/datasets', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'],
}
})
describe('DocTypeSelector', () => {
const defaultProps = {
docType: '' as '' | 'book',
documentType: undefined as '' | 'book' | undefined,
tempDocType: '' as '' | 'book' | 'web_page',
onTempDocTypeChange: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Verify first-time setup UI (no existing doc type)
describe('First Time Selection', () => {
it('should render description and selection title when no doc type exists', () => {
render(<DocTypeSelector {...defaultProps} docType="" documentType={undefined} />)
expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument()
expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument()
})
it('should render icon buttons for each doc type', () => {
const { container } = render(<DocTypeSelector {...defaultProps} />)
// Each doc type renders an IconButton wrapped in Radio
const iconButtons = container.querySelectorAll('button[type="button"]')
// 3 doc types + 1 confirm button = 4 buttons
expect(iconButtons.length).toBeGreaterThanOrEqual(3)
})
it('should render confirm button disabled when tempDocType is empty', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="" />)
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
expect(confirmBtn.closest('button')).toBeDisabled()
})
it('should render confirm button enabled when tempDocType is set', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
expect(confirmBtn.closest('button')).not.toBeDisabled()
})
it('should call onConfirm when confirm button is clicked', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
fireEvent.click(screen.getByText(/metadata\.firstMetaAction/))
expect(defaultProps.onConfirm).toHaveBeenCalled()
})
})
// Verify change-type UI (has existing doc type)
describe('Change Doc Type', () => {
it('should render change title and warning when documentType exists', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument()
expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument()
})
it('should render save and cancel buttons when documentType exists', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
})
it('should call onCancel when cancel button is clicked', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
fireEvent.click(screen.getByText(/operation\.cancel/))
expect(defaultProps.onCancel).toHaveBeenCalled()
})
})
})
describe('DocumentTypeDisplay', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify read-only display of current doc type
describe('Rendering', () => {
it('should render the doc type text', () => {
render(<DocumentTypeDisplay displayType="book" />)
expect(screen.getByText('Book')).toBeInTheDocument()
})
it('should show change link when showChangeLink is true', () => {
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} />)
expect(screen.getByText(/operation\.change/)).toBeInTheDocument()
})
it('should not show change link when showChangeLink is false', () => {
render(<DocumentTypeDisplay displayType="book" showChangeLink={false} />)
expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument()
})
it('should call onChangeClick when change link is clicked', () => {
const onClick = vi.fn()
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />)
fireEvent.click(screen.getByText(/operation\.change/))
expect(onClick).toHaveBeenCalled()
})
it('should fallback to "book" display when displayType is empty and no change link', () => {
render(<DocumentTypeDisplay displayType="" showChangeLink={false} />)
expect(screen.getByText('Book')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,116 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FieldInfo from '../field-info'
vi.mock('@/utils', () => ({
getTextWidthWithCanvas: (text: string) => text.length * 8,
}))
describe('FieldInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify read-only rendering
describe('Read-Only Mode', () => {
it('should render label and displayed value', () => {
render(<FieldInfo label="Title" displayedValue="My Document" />)
expect(screen.getByText('Title')).toBeInTheDocument()
expect(screen.getByText('My Document')).toBeInTheDocument()
})
it('should render value icon when provided', () => {
render(
<FieldInfo
label="Status"
displayedValue="Active"
valueIcon={<span data-testid="icon">*</span>}
/>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
})
it('should render displayedValue as plain text when not editing', () => {
render(<FieldInfo label="Author" displayedValue="John" showEdit={false} />)
expect(screen.getByText('John')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
// Verify edit mode rendering for each inputType
describe('Edit Mode', () => {
it('should render input field by default in edit mode', () => {
render(<FieldInfo label="Title" value="Test" showEdit={true} inputType="input" />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('Test')
})
it('should render textarea when inputType is textarea', () => {
render(<FieldInfo label="Desc" value="Long text" showEdit={true} inputType="textarea" />)
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('Long text')
})
it('should render select when inputType is select', () => {
const options = [
{ value: 'en', name: 'English' },
{ value: 'zh', name: 'Chinese' },
]
render(
<FieldInfo
label="Language"
value="en"
showEdit={true}
inputType="select"
selectOptions={options}
/>,
)
// SimpleSelect renders a button-like trigger
expect(screen.getByText('English')).toBeInTheDocument()
})
it('should call onUpdate when input value changes', () => {
const onUpdate = vi.fn()
render(<FieldInfo label="Title" value="" showEdit={true} inputType="input" onUpdate={onUpdate} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } })
expect(onUpdate).toHaveBeenCalledWith('New')
})
it('should call onUpdate when textarea value changes', () => {
const onUpdate = vi.fn()
render(<FieldInfo label="Desc" value="" showEdit={true} inputType="textarea" onUpdate={onUpdate} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
expect(onUpdate).toHaveBeenCalledWith('Updated')
})
})
// Verify edge cases
describe('Edge Cases', () => {
it('should render with empty value and label', () => {
render(<FieldInfo label="" value="" displayedValue="" />)
// Should not crash
const container = document.querySelector('.flex.min-h-5')
expect(container).toBeInTheDocument()
})
it('should render with default value prop', () => {
render(<FieldInfo label="Field" showEdit={true} inputType="input" defaultValue="default" />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,149 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MetadataFieldList from '../metadata-field-list'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: {
text: 'Book',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
author: { label: 'Author', inputType: 'input' },
},
},
originInfo: {
text: 'Origin Info',
subFieldsMap: {
source: { label: 'Source', inputType: 'input' },
hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` },
},
},
}),
useLanguages: () => ({ en: 'English', zh: 'Chinese' }),
useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }),
usePersonalDocCategories: () => ({}),
useBusinessDocCategories: () => ({}),
}))
describe('MetadataFieldList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify rendering of metadata fields based on mainField
describe('Rendering', () => {
it('should render all fields for the given mainField', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{ title: 'Test Book', language: 'en', author: 'John' }}
/>,
)
expect(screen.getByText('Title')).toBeInTheDocument()
expect(screen.getByText('Language')).toBeInTheDocument()
expect(screen.getByText('Author')).toBeInTheDocument()
})
it('should return null when mainField is empty', () => {
const { container } = render(
<MetadataFieldList mainField="" metadata={{}} />,
)
expect(container.firstChild).toBeNull()
})
it('should display "-" for missing field values', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{}}
/>,
)
// All three fields should show "-"
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThanOrEqual(3)
})
it('should resolve select values to their display name', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{ language: 'en' }}
/>,
)
expect(screen.getByText('English')).toBeInTheDocument()
})
})
// Verify edit mode passes correct props
describe('Edit Mode', () => {
it('should render fields in edit mode when canEdit is true', () => {
render(
<MetadataFieldList
mainField="book"
canEdit={true}
metadata={{ title: 'Book Title' }}
/>,
)
// In edit mode, FieldInfo renders input elements
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
})
it('should call onFieldUpdate when a field value changes', () => {
const onUpdate = vi.fn()
render(
<MetadataFieldList
mainField="book"
canEdit={true}
metadata={{ title: '' }}
onFieldUpdate={onUpdate}
/>,
)
// Find the first textbox and type in it
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0], { target: { value: 'New Title' } })
expect(onUpdate).toHaveBeenCalled()
})
})
// Verify fixed field types use docDetail as source
describe('Fixed Field Types', () => {
it('should use docDetail as source data for originInfo type', () => {
const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 }
render(
<MetadataFieldList
mainField="originInfo"
docDetail={docDetail as never}
metadata={{}}
/>,
)
expect(screen.getByText('Source')).toBeInTheDocument()
expect(screen.getByText('Web')).toBeInTheDocument()
})
it('should render custom render function output for fields with render', () => {
const docDetail = { source: 'API', hit_count: 15, segment_count: 5 }
render(
<MetadataFieldList
mainField="originInfo"
docDetail={docDetail as never}
metadata={{}}
/>,
)
expect(screen.getByText('15 / 5')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,164 @@
import type { ReactNode } from 'react'
import type { FullDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useMetadataState } from '../use-metadata-state'
const { mockNotify, mockModifyDocMetadata } = vi.hoisted(() => ({
mockNotify: vi.fn(),
mockModifyDocMetadata: vi.fn(),
}))
vi.mock('../../../context', () => ({
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) =>
selector({ datasetId: 'ds-1', documentId: 'doc-1' }),
}))
vi.mock('@/service/datasets', () => ({
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
}))
vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) }))
vi.mock('@/utils', () => ({
asyncRunSafe: async (promise: Promise<unknown>) => {
try {
return [null, await promise]
}
catch (e) { return [e] }
},
}))
// Wrapper that provides ToastContext with the mock notify function
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(ToastContext.Provider, { value: { notify: mockNotify, close: vi.fn() }, children })
type DocDetail = Parameters<typeof useMetadataState>[0]['docDetail']
const makeDoc = (overrides: Partial<FullDocumentDetail> = {}): DocDetail =>
({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail)
describe('useMetadataState', () => {
// Verify all metadata editing workflows using a stable docDetail reference
it('should manage the full metadata editing lifecycle', async () => {
mockModifyDocMetadata.mockResolvedValue({ result: 'ok' })
const onUpdate = vi.fn()
// IMPORTANT: Create a stable reference outside the render callback
// to prevent useEffect infinite loops on docDetail?.doc_metadata
const stableDocDetail = makeDoc()
const { result } = renderHook(() =>
useMetadataState({ docDetail: stableDocDetail, onUpdate }), { wrapper })
// --- Initialization ---
expect(result.current.docType).toBe('book')
expect(result.current.editStatus).toBe(false)
expect(result.current.showDocTypes).toBe(false)
expect(result.current.metadataParams.documentType).toBe('book')
expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' })
// --- Enable editing ---
act(() => {
result.current.enableEdit()
})
expect(result.current.editStatus).toBe(true)
// --- Update individual field ---
act(() => {
result.current.updateMetadataField('title', 'Modified Title')
})
expect(result.current.metadataParams.metadata.title).toBe('Modified Title')
expect(result.current.metadataParams.metadata.author).toBe('Author')
// --- Cancel edit restores original data ---
act(() => {
result.current.cancelEdit()
})
expect(result.current.metadataParams.metadata.title).toBe('Test Book')
expect(result.current.editStatus).toBe(false)
// --- Doc type selection: cancel restores previous ---
act(() => {
result.current.enableEdit()
})
act(() => {
result.current.setShowDocTypes(true)
})
act(() => {
result.current.setTempDocType('web_page')
})
act(() => {
result.current.cancelDocType()
})
expect(result.current.tempDocType).toBe('book')
expect(result.current.showDocTypes).toBe(false)
// --- Confirm different doc type clears metadata ---
act(() => {
result.current.setShowDocTypes(true)
})
act(() => {
result.current.setTempDocType('web_page')
})
act(() => {
result.current.confirmDocType()
})
expect(result.current.metadataParams.documentType).toBe('web_page')
expect(result.current.metadataParams.metadata).toEqual({})
// --- Save succeeds ---
await act(async () => {
await result.current.saveMetadata()
})
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentId: 'doc-1',
body: { doc_type: 'web_page', doc_metadata: {} },
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
expect(onUpdate).toHaveBeenCalled()
expect(result.current.editStatus).toBe(false)
expect(result.current.saveLoading).toBe(false)
// --- Save failure notifies error ---
mockNotify.mockClear()
mockModifyDocMetadata.mockRejectedValue(new Error('fail'))
act(() => {
result.current.enableEdit()
})
await act(async () => {
await result.current.saveMetadata()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
// Verify empty doc type starts in editing mode
it('should initialize in editing mode when no doc type exists', () => {
const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] })
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
expect(result.current.docType).toBe('')
expect(result.current.editStatus).toBe(true)
expect(result.current.showDocTypes).toBe(true)
})
// Verify "others" normalization
it('should normalize "others" doc_type to empty string', () => {
const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] })
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
expect(result.current.docType).toBe('')
})
// Verify undefined docDetail handling
it('should handle undefined docDetail gracefully', () => {
const { result } = renderHook(() => useMetadataState({ docDetail: undefined }), { wrapper })
expect(result.current.docType).toBe('')
expect(result.current.editStatus).toBe(true)
})
})

View File

@@ -1,40 +1,49 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { Query } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import QueryInput from '../index'
vi.mock('uuid', () => ({
v4: () => 'mock-uuid',
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => (
<button data-testid="submit-button" onClick={onClick} disabled={disabled || loading}>
{children}
</button>
),
}))
// Capture onChange callback so tests can trigger handleImageChange
let capturedOnChange: ((files: FileEntity[]) => void) | null = null
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
<div data-testid="image-uploader">
{textArea}
{actionButton}
</div>
),
default: ({ textArea, actionButton, onChange }: { textArea: React.ReactNode, actionButton: React.ReactNode, onChange?: (files: FileEntity[]) => void }) => {
capturedOnChange = onChange ?? null
return (
<div data-testid="image-uploader">
{textArea}
{actionButton}
</div>
)
},
}))
vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({
getIcon: () => '/test-icon.png',
}))
// Capture onSave callback for external retrieval modal
let _capturedModalOnSave: ((data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void) | null = null
vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({
default: () => <div data-testid="external-retrieval-modal" />,
default: ({ onSave, onClose }: { onSave: (data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void, onClose: () => void }) => {
_capturedModalOnSave = onSave
return (
<div data-testid="external-retrieval-modal">
<button data-testid="modal-save" onClick={() => onSave({ top_k: 10, score_threshold: 0.8, score_threshold_enabled: true })}>Save</button>
<button data-testid="modal-close" onClick={onClose}>Close</button>
</div>
)
},
}))
// Capture handleTextChange callback
let _capturedHandleTextChange: ((e: React.ChangeEvent<HTMLTextAreaElement>) => void) | null = null
vi.mock('../textarea', () => ({
default: ({ text }: { text: string }) => <textarea data-testid="textarea" defaultValue={text} />,
default: ({ text, handleTextChange }: { text: string, handleTextChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }) => {
_capturedHandleTextChange = handleTextChange
return <textarea data-testid="textarea" defaultValue={text} onChange={handleTextChange} />
},
}))
vi.mock('@/context/dataset-detail', () => ({
@@ -42,7 +51,8 @@ vi.mock('@/context/dataset-detail', () => ({
}))
describe('QueryInput', () => {
const defaultProps = {
// Re-create per test to avoid cross-test mutation (handleTextChange mutates query objects)
const makeDefaultProps = () => ({
onUpdateList: vi.fn(),
setHitResult: vi.fn(),
setExternalHitResult: vi.fn(),
@@ -55,10 +65,16 @@ describe('QueryInput', () => {
isEconomy: false,
hitTestingMutation: vi.fn(),
externalKnowledgeBaseHitTestingMutation: vi.fn(),
}
})
let defaultProps: ReturnType<typeof makeDefaultProps>
beforeEach(() => {
vi.clearAllMocks()
defaultProps = makeDefaultProps()
capturedOnChange = null
_capturedModalOnSave = null
_capturedHandleTextChange = null
})
it('should render title', () => {
@@ -73,7 +89,7 @@ describe('QueryInput', () => {
it('should render submit button', () => {
render(<QueryInput {...defaultProps} />)
expect(screen.getByTestId('submit-button')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /input\.testing/ })).toBeInTheDocument()
})
it('should disable submit button when text is empty', () => {
@@ -82,7 +98,7 @@ describe('QueryInput', () => {
queries: [{ content: '', content_type: 'text_query', file_info: null }] satisfies Query[],
}
render(<QueryInput {...props} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
expect(screen.getByRole('button', { name: /input\.testing/ })).toBeDisabled()
})
it('should render retrieval method for non-external mode', () => {
@@ -101,11 +117,302 @@ describe('QueryInput', () => {
queries: [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] satisfies Query[],
}
render(<QueryInput {...props} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
expect(screen.getByRole('button', { name: /input\.testing/ })).toBeDisabled()
})
it('should disable submit button when loading', () => {
it('should show loading state on submit button when loading', () => {
render(<QueryInput {...defaultProps} loading={true} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
const submitButton = screen.getByRole('button', { name: /input\.testing/ })
// The real Button component does not disable on loading; it shows a spinner
expect(submitButton).toBeInTheDocument()
expect(submitButton.querySelector('[role="status"]')).toBeInTheDocument()
})
// Cover line 83: images useMemo with image_query data
describe('Image Queries', () => {
it('should parse image_query entries from queries', () => {
const queries: Query[] = [
{ content: 'test', content_type: 'text_query', file_info: null },
{
content: 'https://img.example.com/1.png',
content_type: 'image_query',
file_info: { id: 'img-1', name: 'photo.png', size: 1024, mime_type: 'image/png', extension: 'png', source_url: 'https://img.example.com/1.png' },
},
]
render(<QueryInput {...defaultProps} queries={queries} />)
// Submit should be enabled since we have text + uploaded image
expect(screen.getByRole('button', { name: /input\.testing/ })).not.toBeDisabled()
})
})
// Cover lines 106-107: handleSaveExternalRetrievalSettings
describe('External Retrieval Settings', () => {
it('should open and close external retrieval modal', () => {
render(<QueryInput {...defaultProps} isExternal={true} />)
// Click settings button to open modal
fireEvent.click(screen.getByRole('button', { name: /settingTitle/ }))
expect(screen.getByTestId('external-retrieval-modal')).toBeInTheDocument()
// Close modal
fireEvent.click(screen.getByTestId('modal-close'))
expect(screen.queryByTestId('external-retrieval-modal')).not.toBeInTheDocument()
})
it('should save external retrieval settings and close modal', () => {
render(<QueryInput {...defaultProps} isExternal={true} />)
// Open modal
fireEvent.click(screen.getByRole('button', { name: /settingTitle/ }))
expect(screen.getByTestId('external-retrieval-modal')).toBeInTheDocument()
// Save settings
fireEvent.click(screen.getByTestId('modal-save'))
expect(screen.queryByTestId('external-retrieval-modal')).not.toBeInTheDocument()
})
})
// Cover line 121: handleTextChange when textQuery already exists
describe('Text Change Handling', () => {
it('should update existing text query on text change', () => {
render(<QueryInput {...defaultProps} />)
const textarea = screen.getByTestId('textarea')
fireEvent.change(textarea, { target: { value: 'updated text' } })
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content: 'updated text', content_type: 'text_query' }),
]),
)
})
it('should create new text query when none exists', () => {
render(<QueryInput {...defaultProps} queries={[]} />)
const textarea = screen.getByTestId('textarea')
fireEvent.change(textarea, { target: { value: 'new text' } })
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content: 'new text', content_type: 'text_query' }),
]),
)
})
})
// Cover lines 127-143: handleImageChange
describe('Image Change Handling', () => {
it('should update queries when images change', () => {
render(<QueryInput {...defaultProps} />)
const files: FileEntity[] = [{
id: 'f-1',
name: 'pic.jpg',
size: 2048,
mimeType: 'image/jpeg',
extension: 'jpg',
sourceUrl: 'https://img.example.com/pic.jpg',
uploadedId: 'uploaded-1',
progress: 100,
}]
capturedOnChange?.(files)
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content_type: 'text_query' }),
expect.objectContaining({
content: 'https://img.example.com/pic.jpg',
content_type: 'image_query',
file_info: expect.objectContaining({ id: 'uploaded-1', name: 'pic.jpg' }),
}),
]),
)
})
it('should handle files with missing sourceUrl and uploadedId', () => {
render(<QueryInput {...defaultProps} />)
const files: FileEntity[] = [{
id: 'f-2',
name: 'no-url.jpg',
size: 512,
mimeType: 'image/jpeg',
extension: 'jpg',
progress: 100,
// sourceUrl and uploadedId are undefined
}]
capturedOnChange?.(files)
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: '',
content_type: 'image_query',
file_info: expect.objectContaining({ id: '', source_url: '' }),
}),
]),
)
})
it('should replace all existing image queries with new ones', () => {
const queries: Query[] = [
{ content: 'text', content_type: 'text_query', file_info: null },
{ content: 'old-img', content_type: 'image_query', file_info: { id: 'old', name: 'old.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: '' } },
]
render(<QueryInput {...defaultProps} queries={queries} />)
capturedOnChange?.([])
// Should keep text query but remove all image queries
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content_type: 'text_query' }),
]),
)
// Should not contain image_query
const calledWith = defaultProps.setQueries.mock.calls[0][0] as Query[]
expect(calledWith.filter(q => q.content_type === 'image_query')).toHaveLength(0)
})
})
// Cover lines 146-162: onSubmit (hit testing mutation)
describe('Submit Handlers', () => {
it('should call hitTestingMutation on submit for non-external mode', async () => {
const mockMutation = vi.fn(async (_req, opts) => {
const response = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
opts?.onSuccess?.(response)
return response
})
render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
retrieval_model: expect.objectContaining({ search_method: 'semantic_search' }),
}),
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
})
expect(defaultProps.setHitResult).toHaveBeenCalled()
expect(defaultProps.onUpdateList).toHaveBeenCalled()
})
it('should call onSubmit callback after successful hit testing', async () => {
const mockOnSubmit = vi.fn()
const mockMutation = vi.fn(async (_req, opts) => {
const response = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
opts?.onSuccess?.(response)
return response
})
render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} onSubmit={mockOnSubmit} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled()
})
})
it('should use keywordSearch when isEconomy is true', async () => {
const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
const mockMutation = vi.fn(async (_req, opts) => {
opts?.onSuccess?.(mockResponse)
return mockResponse
})
render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} isEconomy={true} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockMutation).toHaveBeenCalledWith(
expect.objectContaining({
retrieval_model: expect.objectContaining({ search_method: 'keyword_search' }),
}),
expect.anything(),
)
})
})
// Cover lines 164-178: externalRetrievalTestingOnSubmit
it('should call externalKnowledgeBaseHitTestingMutation for external mode', async () => {
const mockExternalMutation = vi.fn(async (_req, opts) => {
const response = { query: { content: '' }, records: [] }
opts?.onSuccess?.(response)
return response
})
render(<QueryInput {...defaultProps} isExternal={true} externalKnowledgeBaseHitTestingMutation={mockExternalMutation} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockExternalMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
})
expect(defaultProps.setExternalHitResult).toHaveBeenCalled()
expect(defaultProps.onUpdateList).toHaveBeenCalled()
})
it('should include image attachment_ids in submit request', async () => {
const queries: Query[] = [
{ content: 'test', content_type: 'text_query', file_info: null },
{ content: 'img-url', content_type: 'image_query', file_info: { id: 'img-id', name: 'pic.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: 'img-url' } },
]
const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
const mockMutation = vi.fn(async (_req, opts) => {
opts?.onSuccess?.(mockResponse)
return mockResponse
})
render(<QueryInput {...defaultProps} queries={queries} hitTestingMutation={mockMutation} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockMutation).toHaveBeenCalledWith(
expect.objectContaining({
// uploadedId is mapped from file_info.id
attachment_ids: expect.arrayContaining(['img-id']),
}),
expect.anything(),
)
})
})
})
// Cover lines 217-238: retrieval method click handler
describe('Retrieval Method', () => {
it('should call onClickRetrievalMethod when retrieval method is clicked', () => {
render(<QueryInput {...defaultProps} />)
fireEvent.click(screen.getByText('dataset.retrieval.semantic_search.title'))
expect(defaultProps.onClickRetrievalMethod).toHaveBeenCalled()
})
it('should show keyword_search when isEconomy is true', () => {
render(<QueryInput {...defaultProps} isEconomy={true} />)
expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
})
})
})

View File

@@ -22,6 +22,7 @@ type MetadataItemWithEdit = {
type: DataType
value: string | number | null
isMultipleValue?: boolean
isUpdated?: boolean
updateType?: UpdateType
}
@@ -615,6 +616,91 @@ describe('useBatchEditDocumentMetadata', () => {
})
})
describe('toCleanMetadataItem sanitization', () => {
it('should strip extra fields (isMultipleValue, updateType, isUpdated) from metadata items sent to backend', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value',
isMultipleValue: true,
isUpdated: true,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
const callArgs = mockMutateAsync.mock.calls[0][0]
const sentItem = callArgs.metadata_list[0].metadata_list[0]
// Only id, name, type, value should be present
expect(Object.keys(sentItem).sort()).toEqual(['id', 'name', 'type', 'value'].sort())
expect(sentItem).not.toHaveProperty('isMultipleValue')
expect(sentItem).not.toHaveProperty('updateType')
expect(sentItem).not.toHaveProperty('isUpdated')
})
it('should coerce undefined value to null in metadata items sent to backend', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Pass an item with value explicitly set to undefined (via cast)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: undefined as unknown as null,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
const callArgs = mockMutateAsync.mock.calls[0][0]
const sentItem = callArgs.metadata_list[0].metadata_list[0]
// value should be null, not undefined
expect(sentItem.value).toBeNull()
expect(sentItem.value).not.toBeUndefined()
})
})
describe('Edge Cases', () => {
it('should handle empty docList', () => {
const { result } = renderHook(() =>

View File

@@ -71,6 +71,13 @@ const useBatchEditDocumentMetadata = ({
return res
}, [metaDataList])
const toCleanMetadataItem = (item: MetadataItemWithValue | MetadataItemWithEdit | MetadataItemInBatchEdit): MetadataItemWithValue => ({
id: item.id,
name: item.name,
type: item.type,
value: item.value ?? null,
})
const formateToBackendList = (editedList: MetadataItemWithEdit[], addedList: MetadataItemInBatchEdit[], isApplyToAllSelectDocument: boolean) => {
const updatedList = editedList.filter((editedItem) => {
return editedItem.updateType === UpdateType.changeValue
@@ -92,24 +99,19 @@ const useBatchEditDocumentMetadata = ({
.filter((item) => {
return !removedList.find(removedItem => removedItem.id === item.id)
})
.map(item => ({
id: item.id,
name: item.name,
type: item.type,
value: item.value,
}))
.map(toCleanMetadataItem)
if (isApplyToAllSelectDocument) {
// add missing metadata item
updatedList.forEach((editedItem) => {
if (!newMetadataList.find(i => i.id === editedItem.id) && !editedItem.isMultipleValue)
newMetadataList.push(editedItem)
newMetadataList.push(toCleanMetadataItem(editedItem))
})
}
newMetadataList = newMetadataList.map((item) => {
const editedItem = updatedList.find(i => i.id === item.id)
if (editedItem)
return editedItem
return toCleanMetadataItem(editedItem)
return item
})

View File

@@ -9,23 +9,22 @@ vi.mock('@/utils/clipboard', () => ({
describe('code.tsx components', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.useFakeTimers({ shouldAdvanceTime: true })
// jsdom does not implement scrollBy; mock it to prevent stderr noise
window.scrollBy = vi.fn()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('Code', () => {
it('should render children', () => {
it('should render children as a code element', () => {
render(<Code>const x = 1</Code>)
expect(screen.getByText('const x = 1')).toBeInTheDocument()
})
it('should render as code element', () => {
render(<Code>code snippet</Code>)
const codeElement = screen.getByText('code snippet')
const codeElement = screen.getByText('const x = 1')
expect(codeElement.tagName).toBe('CODE')
})
@@ -48,14 +47,9 @@ describe('code.tsx components', () => {
})
describe('Embed', () => {
it('should render value prop', () => {
it('should render value prop as a span element', () => {
render(<Embed value="embedded content">ignored children</Embed>)
expect(screen.getByText('embedded content')).toBeInTheDocument()
})
it('should render as span element', () => {
render(<Embed value="test value">children</Embed>)
const span = screen.getByText('test value')
const span = screen.getByText('embedded content')
expect(span.tagName).toBe('SPAN')
})
@@ -65,7 +59,7 @@ describe('code.tsx components', () => {
expect(embed).toHaveClass('embed-class')
})
it('should not render children, only value', () => {
it('should render only value, not children', () => {
render(<Embed value="shown">hidden children</Embed>)
expect(screen.getByText('shown')).toBeInTheDocument()
expect(screen.queryByText('hidden children')).not.toBeInTheDocument()
@@ -82,27 +76,6 @@ describe('code.tsx components', () => {
)
expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument()
})
it('should have shadow and rounded styles', () => {
const { container } = render(
<CodeGroup targetCode="code here">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.shadow-md')
expect(codeGroup).toBeInTheDocument()
expect(codeGroup).toHaveClass('rounded-2xl')
})
it('should have bg-zinc-900 background', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
})
describe('with array targetCode', () => {
@@ -184,23 +157,14 @@ describe('code.tsx components', () => {
})
describe('with title prop', () => {
it('should render title in header', () => {
it('should render title in an h3 heading', () => {
render(
<CodeGroup title="API Example" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('API Example')).toBeInTheDocument()
})
it('should render title in h3 element', () => {
render(
<CodeGroup title="Example Title" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const h3 = screen.getByRole('heading', { level: 3 })
expect(h3).toHaveTextContent('Example Title')
expect(h3).toHaveTextContent('API Example')
})
})
@@ -223,30 +187,18 @@ describe('code.tsx components', () => {
expect(screen.getByText('/api/users')).toBeInTheDocument()
})
it('should render both tag and label with separator', () => {
const { container } = render(
it('should render both tag and label together', () => {
render(
<CodeGroup tag="POST" label="/api/create" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('POST')).toBeInTheDocument()
expect(screen.getByText('/api/create')).toBeInTheDocument()
const separator = container.querySelector('.rounded-full.bg-zinc-500')
expect(separator).toBeInTheDocument()
})
})
describe('CopyButton functionality', () => {
it('should render copy button', () => {
render(
<CodeGroup targetCode="copyable code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const copyButton = screen.getByRole('button')
expect(copyButton).toBeInTheDocument()
})
it('should show "Copy" text initially', () => {
render(
<CodeGroup targetCode="code">
@@ -322,88 +274,32 @@ describe('code.tsx components', () => {
expect(screen.getByText('child code content')).toBeInTheDocument()
})
})
describe('styling', () => {
it('should have not-prose class to prevent prose styling', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.not-prose')
expect(codeGroup).toBeInTheDocument()
})
it('should have my-6 margin', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.my-6')
expect(codeGroup).toBeInTheDocument()
})
it('should have overflow-hidden', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.overflow-hidden')
expect(codeGroup).toBeInTheDocument()
})
})
})
describe('Pre', () => {
describe('when outside CodeGroup context', () => {
it('should wrap children in CodeGroup', () => {
const { container } = render(
<Pre>
<pre><code>code content</code></pre>
</Pre>,
)
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
it('should pass props to CodeGroup', () => {
render(
<Pre title="Pre Title">
<pre><code>code</code></pre>
</Pre>,
)
expect(screen.getByText('Pre Title')).toBeInTheDocument()
})
it('should wrap children in CodeGroup when outside CodeGroup context', () => {
render(
<Pre title="Pre Title">
<pre><code>code</code></pre>
</Pre>,
)
expect(screen.getByText('Pre Title')).toBeInTheDocument()
})
describe('when inside CodeGroup context (isGrouped)', () => {
it('should return children directly without wrapping', () => {
render(
<CodeGroup targetCode="outer code">
<Pre>
<code>inner code</code>
</Pre>
</CodeGroup>,
)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
it('should return children directly when inside CodeGroup context', () => {
render(
<CodeGroup targetCode="outer code">
<Pre>
<code>inner code</code>
</Pre>
</CodeGroup>,
)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
})
describe('CodePanelHeader (via CodeGroup)', () => {
it('should not render when neither tag nor label provided', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const headerDivider = container.querySelector('.border-b-white\\/7\\.5')
expect(headerDivider).not.toBeInTheDocument()
})
it('should render when only tag is provided', () => {
it('should render when tag is provided', () => {
render(
<CodeGroup tag="GET" targetCode="code">
<pre><code>fallback</code></pre>
@@ -412,7 +308,7 @@ describe('code.tsx components', () => {
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should render when only label is provided', () => {
it('should render when label is provided', () => {
render(
<CodeGroup label="/api/endpoint" targetCode="code">
<pre><code>fallback</code></pre>
@@ -420,17 +316,6 @@ describe('code.tsx components', () => {
)
expect(screen.getByText('/api/endpoint')).toBeInTheDocument()
})
it('should render label with font-mono styling', () => {
render(
<CodeGroup label="/api/test" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const label = screen.getByText('/api/test')
expect(label.className).toContain('font-mono')
expect(label.className).toContain('text-xs')
})
})
describe('CodeGroupHeader (via CodeGroup with multiple tabs)', () => {
@@ -446,39 +331,10 @@ describe('code.tsx components', () => {
)
expect(screen.getByRole('tablist')).toBeInTheDocument()
})
it('should style active tab differently', () => {
const examples = [
{ title: 'Active', code: 'active code' },
{ title: 'Inactive', code: 'inactive code' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const activeTab = screen.getByRole('tab', { name: 'Active' })
expect(activeTab.className).toContain('border-emerald-500')
expect(activeTab.className).toContain('text-emerald-400')
})
it('should have header background styling', () => {
const examples = [
{ title: 'Tab1', code: 'code1' },
{ title: 'Tab2', code: 'code2' },
]
const { container } = render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const header = container.querySelector('.bg-zinc-800')
expect(header).toBeInTheDocument()
})
})
describe('CodePanel (via CodeGroup)', () => {
it('should render code in pre element', () => {
it('should render code in a pre element', () => {
render(
<CodeGroup targetCode="pre content">
<pre><code>fallback</code></pre>
@@ -487,50 +343,10 @@ describe('code.tsx components', () => {
const preElement = screen.getByText('pre content').closest('pre')
expect(preElement).toBeInTheDocument()
})
it('should have text-white class on pre', () => {
render(
<CodeGroup targetCode="white text">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('white text').closest('pre')
expect(preElement?.className).toContain('text-white')
})
it('should have text-xs class on pre', () => {
render(
<CodeGroup targetCode="small text">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('small text').closest('pre')
expect(preElement?.className).toContain('text-xs')
})
it('should have overflow-x-auto on pre', () => {
render(
<CodeGroup targetCode="scrollable">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('scrollable').closest('pre')
expect(preElement?.className).toContain('overflow-x-auto')
})
it('should have p-4 padding on pre', () => {
render(
<CodeGroup targetCode="padded">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('padded').closest('pre')
expect(preElement?.className).toContain('p-4')
})
})
describe('ClipboardIcon (via CopyButton in CodeGroup)', () => {
it('should render clipboard icon in copy button', () => {
describe('ClipboardIcon (via CopyButton)', () => {
it('should render clipboard SVG icon in copy button', () => {
render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
@@ -543,7 +359,7 @@ describe('code.tsx components', () => {
})
})
describe('edge cases', () => {
describe('Edge Cases', () => {
it('should handle empty string targetCode', () => {
render(
<CodeGroup targetCode="">

View File

@@ -1,12 +1,7 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import InputCopy from '../input-copy'
vi.mock('copy-to-clipboard', () => ({
default: vi.fn().mockReturnValue(true),
}))
async function renderAndFlush(ui: React.ReactElement) {
const result = render(ui)
await act(async () => {
@@ -15,15 +10,21 @@ async function renderAndFlush(ui: React.ReactElement) {
return result
}
const execCommandMock = vi.fn().mockReturnValue(true)
describe('InputCopy', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.useFakeTimers({ shouldAdvanceTime: true })
execCommandMock.mockReturnValue(true)
document.execCommand = execCommandMock
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('rendering', () => {
@@ -107,7 +108,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('copy-this-value')
expect(execCommandMock).toHaveBeenCalledWith('copy')
})
it('should update copied state after clicking', async () => {
@@ -119,7 +120,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('test-value')
expect(execCommandMock).toHaveBeenCalledWith('copy')
})
it('should reset copied state after timeout', async () => {
@@ -131,7 +132,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('test-value')
expect(execCommandMock).toHaveBeenCalledWith('copy')
await act(async () => {
vi.advanceTimersByTime(1500)
@@ -306,7 +307,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledTimes(3)
expect(execCommandMock).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -88,6 +88,8 @@ describe('SecretKeyModal', () => {
beforeEach(() => {
vi.clearAllMocks()
// Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.useFakeTimers({ shouldAdvanceTime: true })
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
mockIsCurrentWorkspaceManager.mockReturnValue(true)
@@ -101,6 +103,7 @@ describe('SecretKeyModal', () => {
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('rendering when shown', () => {

View File

@@ -60,5 +60,11 @@ describe('Category', () => {
const allCategoriesItem = screen.getByText('explore.apps.allCategories')
expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
})
it('should render raw category name when i18n key does not exist', () => {
renderComponent({ list: ['CustomCategory', 'Recommended'] as AppCategory[] })
expect(screen.getByText('CustomCategory')).toBeInTheDocument()
})
})
})

View File

@@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import type { CurrentTryAppParams } from '@/context/explore-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
@@ -55,9 +56,21 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const ContextReader = () => {
const { hasEditPermission } = useContext(ExploreContext)
return <div>{hasEditPermission ? 'edit-yes' : 'edit-no'}</div>
const ContextReader = ({ triggerTryPanel }: { triggerTryPanel?: boolean }) => {
const { hasEditPermission, setShowTryAppPanel, isShowTryAppPanel, currentApp } = useContext(ExploreContext)
return (
<div>
{hasEditPermission ? 'edit-yes' : 'edit-no'}
{isShowTryAppPanel && <span data-testid="try-panel-open">open</span>}
{currentApp && <span data-testid="current-app">{currentApp.appId}</span>}
{triggerTryPanel && (
<>
<button data-testid="show-try" onClick={() => setShowTryAppPanel(true, { appId: 'test-app' } as CurrentTryAppParams)}>show</button>
<button data-testid="hide-try" onClick={() => setShowTryAppPanel(false)}>hide</button>
</>
)}
</div>
)
}
describe('Explore', () => {
@@ -123,5 +136,69 @@ describe('Explore', () => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
it('should skip permission check when membersData has no accounts', () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: undefined })
render((
<Explore>
<ContextReader />
</Explore>
))
expect(screen.getByText('edit-no')).toBeInTheDocument()
})
})
describe('Context: setShowTryAppPanel', () => {
it('should set currentApp params when showing try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
expect(screen.getByTestId('current-app')).toHaveTextContent('test-app')
})
})
it('should clear currentApp params when hiding try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('hide-try'))
await waitFor(() => {
expect(screen.queryByTestId('try-panel-open')).not.toBeInTheDocument()
expect(screen.queryByTestId('current-app')).not.toBeInTheDocument()
})
})
})
})

View File

@@ -2,6 +2,7 @@ import type { AppCardProps } from '../index'
import type { App } from '@/models/explore'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ExploreContext from '@/context/explore-context'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
@@ -136,5 +137,32 @@ describe('AppCard', () => {
expect(screen.getByText('Sample App')).toBeInTheDocument()
})
it('should call setShowTryAppPanel when try button is clicked', () => {
const mockSetShowTryAppPanel = vi.fn()
const app = createApp()
render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: false,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: mockSetShowTryAppPanel,
}}
>
<AppCard app={app} canCreate={true} onCreate={vi.fn()} isExplore={true} />
</ExploreContext.Provider>,
)
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { appId: 'app-id', app })
})
})
})

View File

@@ -1,44 +1,21 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import ExploreContext from '@/context/explore-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import AppList from '../index'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn()
let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] }
let mockIsLoading = false
let mockIsError = false
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockTabValue, mockSetTab],
}
})
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: () => setTimeout(() => fnRef.current(), 0),
}
},
}
})
vi.mock('@/service/use-explore', () => ({
useExploreAppList: () => ({
data: mockExploreData,
@@ -85,6 +62,19 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({
},
}))
vi.mock('../../try-app', () => ({
default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => (
<div data-testid="try-app-panel">
<button data-testid="try-app-create" onClick={onCreate}>create</button>
<button data-testid="try-app-close" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('../../banner/banner', () => ({
default: () => <div data-testid="explore-banner">banner</div>,
}))
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
<div data-testid="dsl-confirm-modal">
@@ -121,35 +111,41 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => {
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
return render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>,
<NuqsTestingAdapter searchParams={searchParams}>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
}
describe('AppList', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockTabValue = allCategoriesEn
mockExploreData = { categories: [], allList: [] }
mockIsLoading = false
mockIsError = false
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render loading when the query is loading', () => {
mockExploreData = undefined
@@ -175,13 +171,12 @@ describe('AppList', () => {
describe('Props', () => {
it('should filter apps by selected category', () => {
mockTabValue = 'Writing'
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
renderWithContext()
renderWithContext(false, undefined, { category: 'Writing' })
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
@@ -199,13 +194,16 @@ describe('AppList', () => {
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('should handle create flow and confirm DSL when pending', async () => {
vi.useRealTimers()
const onSuccess = vi.fn()
mockExploreData = {
categories: ['Writing'],
@@ -247,16 +245,241 @@ describe('AppList', () => {
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('input-clear'))
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('should render nothing when isError is true', () => {
mockIsError = true
mockExploreData = undefined
const { container } = renderWithContext()
expect(container.innerHTML).toBe('')
})
it('should render nothing when data is undefined', () => {
mockExploreData = undefined
const { container } = renderWithContext()
expect(container.innerHTML).toBe('')
})
it('should reset filter when reset button is clicked', async () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('explore.apps.resetFilter'))
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('should close create modal via hide button', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
renderWithContext(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-create'))
await waitFor(() => {
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
it('should close create modal on successful DSL import', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
options.onSuccess?.()
})
renderWithContext(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
it('should cancel DSL confirm modal', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
renderWithContext(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
await waitFor(() => {
expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dsl-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('dsl-confirm-modal')).not.toBeInTheDocument()
})
})
})
describe('TryApp Panel', () => {
it('should open create modal from try app panel', async () => {
vi.useRealTimers()
const mockSetShowTryAppPanel = vi.fn()
const app = createApp()
mockExploreData = {
categories: ['Writing'],
allList: [app],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: mockSetShowTryAppPanel,
currentApp: { appId: 'app-1', app },
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
const createBtn = screen.getByTestId('try-app-create')
fireEvent.click(createBtn)
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should open create modal with null currApp when appParams has no app', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
currentApp: { appId: 'app-1' } as CurrentTryAppParams,
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
fireEvent.click(screen.getByTestId('try-app-create'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should render try app panel with empty appId when currentApp is undefined', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
})
})
describe('Banner', () => {
it('should render banner when enable_explore_banner is true', () => {
useGlobalPublicStore.setState({
systemFeatures: {
...useGlobalPublicStore.getState().systemFeatures,
enable_explore_banner: true,
},
})
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
renderWithContext()
expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
})
})
})

View File

@@ -88,6 +88,9 @@ const createMockAppDetail = (mode: string = 'chat'): TryAppInfo => ({
describe('TryApp (main index.tsx)', () => {
beforeEach(() => {
vi.clearAllMocks()
// Suppress expected React act() warnings from internal async state updates
vi.spyOn(console, 'error').mockImplementation(() => {})
mockUseGetTryAppInfo.mockReturnValue({
data: createMockAppDetail(),
isLoading: false,
@@ -96,7 +99,7 @@ describe('TryApp (main index.tsx)', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('loading state', () => {

View File

@@ -13,10 +13,6 @@ vi.mock('../../../app/type-selector', () => ({
AppTypeIcon: () => null,
}))
vi.mock('../../../base/app-icon', () => ({
default: () => null,
}))
describe('appAction', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -62,10 +58,13 @@ describe('appAction', () => {
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
const results = await appAction.search('@app fail', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('App search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@@ -146,6 +146,7 @@ describe('searchAnything', () => {
})
it('handles action search failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const action: ActionItem = {
key: '@app',
shortcut: '@app',
@@ -156,6 +157,11 @@ describe('searchAnything', () => {
const results = await searchAnything('en', '@app test', action)
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Search failed for @app'),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('runs global search across all non-slash actions for plain queries', async () => {
@@ -183,6 +189,7 @@ describe('searchAnything', () => {
})
it('handles partial search failures in global search gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const dynamicActions: Record<string, ActionItem> = {
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) },
knowledge: {
@@ -200,6 +207,8 @@ describe('searchAnything', () => {
expect(results).toHaveLength(1)
expect(results[0].id).toBe('k1')
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
})

View File

@@ -9,10 +9,6 @@ vi.mock('@/utils/classnames', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}))
vi.mock('../../../base/icons/src/vender/solid/files', () => ({
Folder: () => null,
}))
describe('knowledgeAction', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -84,10 +80,13 @@ describe('knowledgeAction', () => {
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Knowledge search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@@ -55,18 +55,27 @@ describe('pluginAction', () => {
})
it('returns empty array when response has unexpected structure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
const results = await pluginAction.search('@plugin', 'test', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Plugin search: Unexpected response structure',
expect.anything(),
)
warnSpy.mockRestore()
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
const results = await pluginAction.search('@plugin', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@@ -12,9 +12,10 @@ import { forumCommand } from '../forum'
vi.mock('../command-bus')
const mockT = vi.fn((key: string) => key)
vi.mock('react-i18next', () => ({
getI18n: () => ({
t: (key: string) => key,
t: (key: string) => mockT(key),
language: 'en',
}),
}))
@@ -62,11 +63,32 @@ describe('docsCommand', () => {
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) =>
key.includes('docDesc') ? '' : key,
)
const results = await docsCommand.search('', 'en')
expect(results[0].description).toBe('Open help documentation')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.doc command', () => {
docsCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
})
it('registered handler opens doc URL with correct locale', async () => {
docsCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.doc']()
expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('unregisters navigation.doc command', () => {
docsCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
@@ -154,11 +176,42 @@ describe('communityCommand', () => {
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) =>
key.includes('communityDesc') ? '' : key,
)
const results = await communityCommand.search('', 'en')
expect(results[0].description).toBe('Open Discord community')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.community command', () => {
communityCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) })
})
it('registered handler opens URL from args', async () => {
communityCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.community']({ url: 'https://custom-url.com' })
expect(openSpy).toHaveBeenCalledWith('https://custom-url.com', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('registered handler falls back to default URL when no args', async () => {
communityCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.community']()
expect(openSpy).toHaveBeenCalledWith('https://discord.gg/5AEfbxcd9k', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('unregisters navigation.community command', () => {
communityCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community'])
@@ -200,11 +253,42 @@ describe('forumCommand', () => {
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) =>
key.includes('feedbackDesc') ? '' : key,
)
const results = await forumCommand.search('', 'en')
expect(results[0].description).toBe('Open community feedback discussions')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.forum command', () => {
forumCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) })
})
it('registered handler opens URL from args', async () => {
forumCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.forum']({ url: 'https://custom-forum.com' })
expect(openSpy).toHaveBeenCalledWith('https://custom-forum.com', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('registered handler falls back to default URL when no args', async () => {
forumCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.forum']()
expect(openSpy).toHaveBeenCalledWith('https://forum.dify.ai', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('unregisters navigation.forum command', () => {
forumCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum'])

View File

@@ -214,6 +214,7 @@ describe('SlashCommandRegistry', () => {
})
it('returns empty when handler.search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const handler = createHandler({
name: 'broken',
search: vi.fn().mockRejectedValue(new Error('fail')),
@@ -222,6 +223,11 @@ describe('SlashCommandRegistry', () => {
const results = await registry.search('/broken')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Command search failed'),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('excludes unavailable commands from root listing', async () => {

View File

@@ -7,142 +7,55 @@ describe('useTags', () => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return tags array', () => {
const { result } = renderHook(() => useTags())
it('should return non-empty tags array with name and label properties', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tags).toBeDefined()
expect(Array.isArray(result.current.tags)).toBe(true)
expect(result.current.tags.length).toBeGreaterThan(0)
})
it('should return tags with translated labels', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(tag.label).toBe(`pluginTags.tags.${tag.name}`)
})
})
it('should return tags with name and label properties', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(tag).toHaveProperty('name')
expect(tag).toHaveProperty('label')
expect(typeof tag.name).toBe('string')
expect(typeof tag.label).toBe('string')
})
})
it('should return tagsMap object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap).toBeDefined()
expect(typeof result.current.tagsMap).toBe('object')
expect(result.current.tags.length).toBeGreaterThan(0)
result.current.tags.forEach((tag) => {
expect(typeof tag.name).toBe('string')
expect(tag.label).toBe(`pluginTags.tags.${tag.name}`)
})
})
describe('tagsMap', () => {
it('should map tag name to tag object', () => {
const { result } = renderHook(() => useTags())
it('should build a tagsMap that maps every tag name to its object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap.agent).toBeDefined()
expect(result.current.tagsMap.agent.name).toBe('agent')
expect(result.current.tagsMap.agent.label).toBe('pluginTags.tags.agent')
})
it('should contain all tags from tags array', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(result.current.tagsMap[tag.name]).toBeDefined()
expect(result.current.tagsMap[tag.name]).toEqual(tag)
})
result.current.tags.forEach((tag) => {
expect(result.current.tagsMap[tag.name]).toEqual(tag)
})
})
describe('getTagLabel', () => {
it('should return label for existing tag', () => {
it('should return translated label for existing tags', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('agent')).toBe('pluginTags.tags.agent')
expect(result.current.getTagLabel('search')).toBe('pluginTags.tags.search')
expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag')
})
it('should return name for non-existing tag', () => {
it('should return the name itself for non-existing tags', () => {
const { result } = renderHook(() => useTags())
// Test non-existing tags - this covers the branch where !tagsMap[name]
expect(result.current.getTagLabel('non-existing')).toBe('non-existing')
expect(result.current.getTagLabel('custom-tag')).toBe('custom-tag')
})
it('should cover both branches of getTagLabel conditional', () => {
it('should handle edge cases: empty string and special characters', () => {
const { result } = renderHook(() => useTags())
const existingTagResult = result.current.getTagLabel('rag')
expect(existingTagResult).toBe('pluginTags.tags.rag')
const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz')
expect(nonExistingTagResult).toBe('unknown-tag-xyz')
})
it('should be a function', () => {
const { result } = renderHook(() => useTags())
expect(typeof result.current.getTagLabel).toBe('function')
})
it('should return correct labels for all predefined tags', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag')
expect(result.current.getTagLabel('image')).toBe('pluginTags.tags.image')
expect(result.current.getTagLabel('videos')).toBe('pluginTags.tags.videos')
expect(result.current.getTagLabel('weather')).toBe('pluginTags.tags.weather')
expect(result.current.getTagLabel('finance')).toBe('pluginTags.tags.finance')
expect(result.current.getTagLabel('design')).toBe('pluginTags.tags.design')
expect(result.current.getTagLabel('travel')).toBe('pluginTags.tags.travel')
expect(result.current.getTagLabel('social')).toBe('pluginTags.tags.social')
expect(result.current.getTagLabel('news')).toBe('pluginTags.tags.news')
expect(result.current.getTagLabel('medical')).toBe('pluginTags.tags.medical')
expect(result.current.getTagLabel('productivity')).toBe('pluginTags.tags.productivity')
expect(result.current.getTagLabel('education')).toBe('pluginTags.tags.education')
expect(result.current.getTagLabel('business')).toBe('pluginTags.tags.business')
expect(result.current.getTagLabel('entertainment')).toBe('pluginTags.tags.entertainment')
expect(result.current.getTagLabel('utilities')).toBe('pluginTags.tags.utilities')
expect(result.current.getTagLabel('other')).toBe('pluginTags.tags.other')
})
it('should handle empty string tag name', () => {
const { result } = renderHook(() => useTags())
// Empty string tag doesn't exist, so should return the empty string
expect(result.current.getTagLabel('')).toBe('')
})
it('should handle special characters in tag name', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('tag-with-dashes')).toBe('tag-with-dashes')
expect(result.current.getTagLabel('tag_with_underscores')).toBe('tag_with_underscores')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useTags())
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useTags())
const firstTagsLength = result.current.tags.length
const firstTagNames = result.current.tags.map(t => t.name)
rerender()
// Structure should remain consistent
expect(result.current.tags.length).toBe(firstTagsLength)
expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames)
})
const firstTagNames = result.current.tags.map(t => t.name)
rerender()
expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames)
})
})
@@ -151,93 +64,46 @@ describe('useCategories', () => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return categories array', () => {
const { result } = renderHook(() => useCategories())
it('should return non-empty categories array with name and label properties', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categories).toBeDefined()
expect(Array.isArray(result.current.categories)).toBe(true)
expect(result.current.categories.length).toBeGreaterThan(0)
})
it('should return categories with name and label properties', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(category).toHaveProperty('name')
expect(category).toHaveProperty('label')
expect(typeof category.name).toBe('string')
expect(typeof category.label).toBe('string')
})
})
it('should return categoriesMap object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap).toBeDefined()
expect(typeof result.current.categoriesMap).toBe('object')
expect(result.current.categories.length).toBeGreaterThan(0)
result.current.categories.forEach((category) => {
expect(typeof category.name).toBe('string')
expect(typeof category.label).toBe('string')
})
})
describe('categoriesMap', () => {
it('should map category name to category object', () => {
const { result } = renderHook(() => useCategories())
it('should build a categoriesMap that maps every category name to its object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool).toBeDefined()
expect(result.current.categoriesMap.tool.name).toBe('tool')
})
it('should contain all categories from categories array', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(result.current.categoriesMap[category.name]).toBeDefined()
expect(result.current.categoriesMap[category.name]).toEqual(category)
})
result.current.categories.forEach((category) => {
expect(result.current.categoriesMap[category.name]).toEqual(category)
})
})
describe('isSingle parameter', () => {
it('should use plural labels when isSingle is false', () => {
const { result } = renderHook(() => useCategories(false))
expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools')
})
it('should use plural labels when isSingle is undefined', () => {
it('should use plural labels by default', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools')
expect(result.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents')
})
it('should use singular labels when isSingle is true', () => {
const { result } = renderHook(() => useCategories(true))
expect(result.current.categoriesMap.tool.label).toBe('plugin.categorySingle.tool')
})
it('should handle agent category specially', () => {
const { result: resultPlural } = renderHook(() => useCategories(false))
const { result: resultSingle } = renderHook(() => useCategories(true))
expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents')
expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent')
expect(result.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useCategories())
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useCategories())
const firstCategoriesLength = result.current.categories.length
const firstCategoryNames = result.current.categories.map(c => c.name)
rerender()
// Structure should remain consistent
expect(result.current.categories.length).toBe(firstCategoriesLength)
expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames)
})
const firstCategoryNames = result.current.categories.map(c => c.name)
rerender()
expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames)
})
})
@@ -246,103 +112,26 @@ describe('usePluginPageTabs', () => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return tabs array', () => {
const { result } = renderHook(() => usePluginPageTabs())
it('should return two tabs: plugins first, marketplace second', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current).toBeDefined()
expect(Array.isArray(result.current)).toBe(true)
})
it('should return two tabs', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current.length).toBe(2)
})
it('should return tabs with value and text properties', () => {
const { result } = renderHook(() => usePluginPageTabs())
result.current.forEach((tab) => {
expect(tab).toHaveProperty('value')
expect(tab).toHaveProperty('text')
expect(typeof tab.value).toBe('string')
expect(typeof tab.text).toBe('string')
})
})
it('should return tabs with translated texts', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[0].text).toBe('common.menus.plugins')
expect(result.current[1].text).toBe('common.menus.exploreMarketplace')
})
expect(result.current).toHaveLength(2)
expect(result.current[0]).toEqual({ value: 'plugins', text: 'common.menus.plugins' })
expect(result.current[1]).toEqual({ value: 'discover', text: 'common.menus.exploreMarketplace' })
})
describe('Tab Values', () => {
it('should have plugins tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
it('should have consistent structure across re-renders', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins)
expect(pluginsTab).toBeDefined()
expect(pluginsTab?.value).toBe('plugins')
expect(pluginsTab?.text).toBe('common.menus.plugins')
})
it('should have marketplace tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace)
expect(marketplaceTab).toBeDefined()
expect(marketplaceTab?.value).toBe('discover')
expect(marketplaceTab?.text).toBe('common.menus.exploreMarketplace')
})
})
describe('Tab Order', () => {
it('should return plugins tab as first tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[0].value).toBe('plugins')
expect(result.current[0].text).toBe('common.menus.plugins')
})
it('should return marketplace tab as second tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[1].value).toBe('discover')
expect(result.current[1].text).toBe('common.menus.exploreMarketplace')
})
})
describe('Tab Structure', () => {
it('should have consistent structure across re-renders', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = [...result.current]
rerender()
expect(result.current).toEqual(firstTabs)
})
it('should return new array reference on each call', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = result.current
rerender()
// Each call creates a new array (not memoized)
expect(result.current).not.toBe(firstTabs)
})
const firstTabs = [...result.current]
rerender()
expect(result.current).toEqual(firstTabs)
})
})
describe('PLUGIN_PAGE_TABS_MAP', () => {
it('should have plugins key with correct value', () => {
it('should have correct key-value mappings', () => {
expect(PLUGIN_PAGE_TABS_MAP.plugins).toBe('plugins')
})
it('should have marketplace key with correct value', () => {
expect(PLUGIN_PAGE_TABS_MAP.marketplace).toBe('discover')
})
})

View File

@@ -0,0 +1,171 @@
import type { Mock } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('useFoldAnimInto', () => {
let mockOnClose: Mock<() => void>
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
mockOnClose = vi.fn<() => void>()
})
afterEach(() => {
vi.useRealTimers()
document.querySelectorAll('.install-modal, #plugin-task-trigger, .plugins-nav-button')
.forEach(el => el.remove())
})
it('should return modalClassName and functions', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
expect(result.current.modalClassName).toBe('install-modal')
expect(typeof result.current.foldIntoAnim).toBe('function')
expect(typeof result.current.clearCountDown).toBe('function')
expect(typeof result.current.countDownFoldIntoAnim).toBe('function')
})
describe('foldIntoAnim', () => {
it('should call onClose immediately when modal element is not found', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
await act(async () => {
await result.current.foldIntoAnim()
})
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when modal exists but trigger element is not found', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
const modal = document.createElement('div')
modal.className = 'install-modal'
document.body.appendChild(modal)
await act(async () => {
await result.current.foldIntoAnim()
})
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should animate and call onClose when both elements exist', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
const modal = document.createElement('div')
modal.className = 'install-modal'
Object.defineProperty(modal, 'getBoundingClientRect', {
value: () => ({ left: 100, top: 100, width: 400, height: 300 }),
})
document.body.appendChild(modal)
// Set up trigger element with id
const trigger = document.createElement('div')
trigger.id = 'plugin-task-trigger'
Object.defineProperty(trigger, 'getBoundingClientRect', {
value: () => ({ left: 50, top: 50, width: 40, height: 40 }),
})
document.body.appendChild(trigger)
await act(async () => {
await result.current.foldIntoAnim()
})
// Should apply animation styles
expect(modal.style.transition).toContain('750ms')
expect(modal.style.transform).toContain('translate')
expect(modal.style.transform).toContain('scale')
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should use plugins-nav-button as fallback trigger element', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
const modal = document.createElement('div')
modal.className = 'install-modal'
Object.defineProperty(modal, 'getBoundingClientRect', {
value: () => ({ left: 200, top: 200, width: 500, height: 400 }),
})
document.body.appendChild(modal)
// No #plugin-task-trigger, use .plugins-nav-button fallback
const navButton = document.createElement('div')
navButton.className = 'plugins-nav-button'
Object.defineProperty(navButton, 'getBoundingClientRect', {
value: () => ({ left: 10, top: 10, width: 30, height: 30 }),
})
document.body.appendChild(navButton)
await act(async () => {
await result.current.foldIntoAnim()
})
expect(modal.style.transform).toContain('translate')
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
describe('clearCountDown', () => {
it('should clear the countdown timer', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
// Start countdown then clear it
await act(async () => {
result.current.countDownFoldIntoAnim()
})
result.current.clearCountDown()
// Advance past the countdown time — onClose should NOT be called
await act(async () => {
vi.advanceTimersByTime(20000)
})
// onClose might still be called because foldIntoAnim's inner logic
// could fire, but the setTimeout itself should be cleared
})
})
describe('countDownFoldIntoAnim', () => {
it('should trigger foldIntoAnim after 15 seconds', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
await act(async () => {
result.current.countDownFoldIntoAnim()
})
// Advance by 15 seconds
await act(async () => {
vi.advanceTimersByTime(15000)
})
// foldIntoAnim would be called, but no modal in DOM so onClose is called directly
expect(mockOnClose).toHaveBeenCalled()
})
it('should not trigger before 15 seconds', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
await act(async () => {
result.current.countDownFoldIntoAnim()
})
// Advance only 10 seconds
await act(async () => {
vi.advanceTimersByTime(10000)
})
expect(mockOnClose).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,268 @@
import type { Dependency, InstallStatus, Plugin } from '../../../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InstallStep } from '../../../types'
import ReadyToInstall from '../ready-to-install'
// Track the onInstalled callback from the Install component
let capturedOnInstalled: ((plugins: Plugin[], installStatus: InstallStatus[]) => void) | null = null
vi.mock('../steps/install', () => ({
default: ({
allPlugins,
onCancel,
onStartToInstall,
onInstalled,
isFromMarketPlace,
}: {
allPlugins: Dependency[]
onCancel: () => void
onStartToInstall: () => void
onInstalled: (plugins: Plugin[], installStatus: InstallStatus[]) => void
isFromMarketPlace?: boolean
}) => {
capturedOnInstalled = onInstalled
return (
<div data-testid="install-step">
<span data-testid="install-plugins-count">{allPlugins?.length}</span>
<span data-testid="install-from-marketplace">{String(!!isFromMarketPlace)}</span>
<button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button>
<button data-testid="install-start-btn" onClick={onStartToInstall}>Start</button>
<button
data-testid="install-complete-btn"
onClick={() => onInstalled(
[{ plugin_id: 'p1', name: 'Plugin 1' } as Plugin],
[{ success: true, isFromMarketPlace: true }],
)}
>
Complete
</button>
</div>
)
},
}))
vi.mock('../steps/installed', () => ({
default: ({
list,
installStatus,
onCancel,
}: {
list: Plugin[]
installStatus: InstallStatus[]
onCancel: () => void
}) => (
<div data-testid="installed-step">
<span data-testid="installed-count">{list.length}</span>
<span data-testid="installed-status-count">{installStatus.length}</span>
<button data-testid="installed-close-btn" onClick={onCancel}>Close</button>
</div>
),
}))
const createMockDependencies = (): Dependency[] => [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'plugin-1-uid',
},
} as Dependency,
{
type: 'github',
value: {
repo: 'test/plugin2',
version: 'v1.0.0',
package: 'plugin2.zip',
},
} as Dependency,
]
describe('ReadyToInstall', () => {
const mockOnStepChange = vi.fn()
const mockOnStartToInstall = vi.fn()
const mockSetIsInstalling = vi.fn()
const mockOnClose = vi.fn()
const defaultProps = {
step: InstallStep.readyToInstall,
onStepChange: mockOnStepChange,
onStartToInstall: mockOnStartToInstall,
setIsInstalling: mockSetIsInstalling,
allPlugins: createMockDependencies(),
onClose: mockOnClose,
}
beforeEach(() => {
vi.clearAllMocks()
capturedOnInstalled = null
})
describe('readyToInstall step', () => {
it('should render Install component when step is readyToInstall', () => {
render(<ReadyToInstall {...defaultProps} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
})
it('should pass allPlugins count to Install component', () => {
render(<ReadyToInstall {...defaultProps} />)
expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('2')
})
it('should pass isFromMarketPlace to Install component', () => {
render(<ReadyToInstall {...defaultProps} isFromMarketPlace />)
expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('true')
})
it('should pass onClose as onCancel to Install', () => {
render(<ReadyToInstall {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-cancel-btn'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should pass onStartToInstall to Install', () => {
render(<ReadyToInstall {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-start-btn'))
expect(mockOnStartToInstall).toHaveBeenCalledTimes(1)
})
})
describe('handleInstalled callback', () => {
it('should transition to installed step when Install completes', () => {
render(<ReadyToInstall {...defaultProps} />)
// Trigger the onInstalled callback via the mock button
fireEvent.click(screen.getByTestId('install-complete-btn'))
// Should update step to installed
expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed)
// Should set isInstalling to false
expect(mockSetIsInstalling).toHaveBeenCalledWith(false)
})
it('should store installed plugins and status for the Installed step', () => {
const { rerender } = render(<ReadyToInstall {...defaultProps} />)
// Trigger install completion
fireEvent.click(screen.getByTestId('install-complete-btn'))
// Re-render with step=installed to show Installed component
rerender(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('installed-count')).toHaveTextContent('1')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('1')
})
it('should pass custom plugins and status via capturedOnInstalled', () => {
const { rerender } = render(<ReadyToInstall {...defaultProps} />)
// Use the captured callback directly with custom data
expect(capturedOnInstalled).toBeTruthy()
act(() => {
capturedOnInstalled!(
[
{ plugin_id: 'p1', name: 'P1' } as Plugin,
{ plugin_id: 'p2', name: 'P2' } as Plugin,
],
[
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: false },
],
)
})
expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed)
expect(mockSetIsInstalling).toHaveBeenCalledWith(false)
// Re-render at installed step
rerender(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.getByTestId('installed-count')).toHaveTextContent('2')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('2')
})
})
describe('installed step', () => {
it('should render Installed component when step is installed', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
it('should pass onClose to Installed component', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
fireEvent.click(screen.getByTestId('installed-close-btn'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should render empty installed list initially', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.getByTestId('installed-count')).toHaveTextContent('0')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('0')
})
})
describe('edge cases', () => {
it('should render nothing when step is neither readyToInstall nor installed', () => {
const { container } = render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.uploading}
/>,
)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
// Only the empty fragment wrapper
expect(container.innerHTML).toBe('')
})
it('should handle empty allPlugins array', () => {
render(
<ReadyToInstall
{...defaultProps}
allPlugins={[]}
/>,
)
expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('0')
})
})
})

View File

@@ -0,0 +1,246 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_SORT } from '../constants'
const createWrapper = (searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
return { wrapper, onUrlUpdate }
}
describe('Marketplace sort atoms', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return default sort value from useMarketplaceSort', async () => {
const { useMarketplaceSort } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
expect(result.current[0]).toEqual(DEFAULT_SORT)
expect(typeof result.current[1]).toBe('function')
})
it('should return default sort value from useMarketplaceSortValue', async () => {
const { useMarketplaceSortValue } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper })
expect(result.current).toEqual(DEFAULT_SORT)
})
it('should return setter from useSetMarketplaceSort', async () => {
const { useSetMarketplaceSort } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSetMarketplaceSort(), { wrapper })
expect(typeof result.current).toBe('function')
})
it('should update sort value via useMarketplaceSort setter', async () => {
const { useMarketplaceSort } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
act(() => {
result.current[1]({ sortBy: 'created_at', sortOrder: 'ASC' })
})
expect(result.current[0]).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
})
describe('useSearchPluginText', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty string as default', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(result.current[0]).toBe('')
expect(typeof result.current[1]).toBe('function')
})
it('should parse q from search params', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper('?q=hello')
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(result.current[0]).toBe('hello')
})
it('should expose a setter function for search text', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(typeof result.current[1]).toBe('function')
// Calling the setter should not throw
await act(async () => {
result.current[1]('search term')
})
})
})
describe('useActivePluginType', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return "all" as default category', async () => {
const { useActivePluginType } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useActivePluginType(), { wrapper })
expect(result.current[0]).toBe('all')
})
it('should parse category from search params', async () => {
const { useActivePluginType } = await import('../atoms')
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useActivePluginType(), { wrapper })
expect(result.current[0]).toBe('tool')
})
})
describe('useFilterPluginTags', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty array as default', async () => {
const { useFilterPluginTags } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
expect(result.current[0]).toEqual([])
})
it('should parse tags from search params', async () => {
const { useFilterPluginTags } = await import('../atoms')
const { wrapper } = createWrapper('?tags=search')
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
expect(result.current[0]).toEqual(['search'])
})
})
describe('useMarketplaceSearchMode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return false when no search text, no tags, and category has collections (all)', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
// "all" is in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode should be false
expect(result.current).toBe(false)
})
it('should return true when search text is present', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?q=test&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
})
it('should return true when tags are present', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?tags=search&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
})
it('should return true when category does not have collections (e.g. model)', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?category=model')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
// "model" is NOT in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode = true
expect(result.current).toBe(true)
})
it('should return false when category has collections (tool) and no search/tags', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(false)
})
})
describe('useMarketplaceMoreClick', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return a callback function', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
expect(typeof result.current).toBe('function')
})
it('should do nothing when called with no params', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
// Should not throw when called with undefined
act(() => {
result.current(undefined)
})
})
it('should update search state when called with search params', async () => {
const { useMarketplaceMoreClick, useMarketplaceSortValue } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
}), { wrapper })
act(() => {
result.current.handleMoreClick({
query: 'collection search',
sort_by: 'created_at',
sort_order: 'ASC',
})
})
// Sort should be updated via the jotai atom
expect(result.current.sort).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
it('should use defaults when search params fields are missing', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
act(() => {
result.current({})
})
})
})

View File

@@ -0,0 +1,369 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Integration tests for hooks.ts using real @tanstack/react-query
* instead of mocking it, to get proper V8 coverage of queryFn closures.
*/
let mockPostMarketplaceShouldFail = false
const mockPostMarketplaceResponse = {
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
total: 1,
},
}
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(async () => {
if (mockPostMarketplaceShouldFail)
throw new Error('Mock API error')
return mockPostMarketplaceResponse
}),
}))
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
},
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplaceCollectionsAndPlugins (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollections.mockResolvedValue({
data: {
collections: [
{ name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
],
},
})
mockCollectionPlugins.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
},
})
})
it('should fetch collections with real QueryClient when query is triggered', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
// Trigger query
result.current.queryMarketplaceCollectionsAndPlugins({ condition: 'category=tool' })
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
})
it('should handle query with empty params (truthy)', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
result.current.queryMarketplaceCollectionsAndPlugins({})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
it('should handle query without arguments (falsy branch)', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
// Call without arguments → query is undefined → falsy branch
result.current.queryMarketplaceCollectionsAndPlugins()
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
})
describe('useMarketplacePluginsByCollectionId (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollectionPlugins.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
},
})
})
it('should return empty when collectionId is undefined', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId(undefined),
{ wrapper: Wrapper },
)
expect(result.current.plugins).toEqual([])
})
it('should fetch plugins when collectionId is provided', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('collection-1'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.plugins.length).toBeGreaterThan(0)
})
})
describe('useMarketplacePlugins (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPostMarketplaceShouldFail = false
})
it('should return initial state without query', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.plugins).toBeUndefined()
expect(result.current.total).toBeUndefined()
expect(result.current.page).toBe(0)
expect(result.current.isLoading).toBe(false)
})
it('should show isLoading during initial fetch', async () => {
// Delay the response so we can observe the loading state
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockImplementationOnce(() => new Promise((resolve) => {
setTimeout(() => resolve({
data: { plugins: [], total: 0 },
}), 200)
}))
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'loading-test' })
// The isLoading should be true while fetching with no data
// (isPending || (isFetching && !data))
await waitFor(() => {
expect(result.current.isLoading).toBe(true)
})
// Eventually completes
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
})
it('should fetch plugins when queryPlugins is called', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
category: 'tool',
sort_by: 'install_count',
sort_order: 'DESC',
page_size: 40,
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins!.length).toBeGreaterThan(0)
expect(result.current.total).toBe(1)
expect(result.current.page).toBe(1)
})
it('should handle bundle type query', async () => {
mockPostMarketplaceShouldFail = false
const bundleResponse = {
data: {
plugins: [],
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }],
total: 1,
},
}
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValueOnce(bundleResponse)
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
type: 'bundle',
page_size: 40,
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should handle API error gracefully', async () => {
mockPostMarketplaceShouldFail = true
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'failing',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins).toEqual([])
expect(result.current.total).toBe(0)
})
it('should reset plugins state', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'test' })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
result.current.resetPlugins()
await waitFor(() => {
expect(result.current.plugins).toBeUndefined()
})
})
it('should use default page_size of 40 when not provided', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
category: 'all',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should handle queryPluginsWithDebounced', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPluginsWithDebounced({
query: 'debounced',
})
// Real useDebounceFn has 500ms wait, so increase timeout
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
}, { timeout: 3000 })
})
it('should handle response with bundles field (bundles || plugins fallback)', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValueOnce({
data: {
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }],
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
total: 2,
},
})
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test-bundles-fallback',
type: 'bundle',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
// Should use bundles (truthy first in || chain)
expect(result.current.plugins!.length).toBeGreaterThan(0)
})
it('should handle response with no bundles and no plugins (empty fallback)', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValueOnce({
data: {
total: 0,
},
})
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test-empty-fallback',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins).toEqual([])
})
})

View File

@@ -1,10 +1,8 @@
import { render, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ================================
// Mock External Dependencies
// ================================
vi.mock('@/i18n-config/i18next-config', () => ({
default: {
getFixedT: () => (key: string) => key,
@@ -26,62 +24,19 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
const mockFetchNextPage = vi.fn()
const mockHasNextPage = false
let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined
let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => {
capturedQueryFn = queryFn
if (queryFn) {
const controller = new AbortController()
queryFn({ signal: controller.signal }).catch(() => {})
}
return {
data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined,
isFetching: false,
isPending: false,
isSuccess: enabled,
}
}),
useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: {
queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>
getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined
enabled: boolean
}) => {
capturedInfiniteQueryFn = queryFn
capturedGetNextPageParam = getNextPageParam
if (queryFn) {
const controller = new AbortController()
queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {})
}
if (getNextPageParam) {
getNextPageParam({ page: 1, page_size: 40, total: 100 })
getNextPageParam({ page: 3, page_size: 40, total: 100 })
}
return {
data: mockInfiniteQueryData,
isPending: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: mockHasNextPage,
fetchNextPage: mockFetchNextPage,
}
}),
useQueryClient: vi.fn(() => ({
removeQueries: vi.fn(),
})),
}))
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: fn,
cancel: vi.fn(),
}),
}))
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return { Wrapper, queryClient }
}
let mockPostMarketplaceShouldFail = false
const mockPostMarketplaceResponse = {
@@ -150,59 +105,26 @@ vi.mock('@/service/client', () => ({
},
}))
// ================================
// useMarketplaceCollectionsAndPlugins Tests
// ================================
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return initial state correctly', async () => {
it('should return initial state with all required properties', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
expect(result.current.setMarketplaceCollections).toBeDefined()
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
})
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
})
it('should provide setMarketplaceCollections function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollections).toBe('function')
})
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
})
it('should return marketplaceCollections from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollections).toBeUndefined()
})
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
})
})
// ================================
// useMarketplacePluginsByCollectionId Tests
// ================================
describe('useMarketplacePluginsByCollectionId', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -210,7 +132,11 @@ describe('useMarketplacePluginsByCollectionId', () => {
it('should return initial state when collectionId is undefined', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined))
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId(undefined),
{ wrapper: Wrapper },
)
expect(result.current.plugins).toEqual([])
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
@@ -218,39 +144,54 @@ describe('useMarketplacePluginsByCollectionId', () => {
it('should return isLoading false when collectionId is provided and query completes', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'))
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('test-collection'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.isLoading).toBe(false)
})
it('should accept query parameter', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() =>
useMarketplacePluginsByCollectionId('test-collection', {
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('test-collection', {
category: 'tool',
type: 'plugin',
}))
expect(result.current.plugins).toBeDefined()
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should return plugins property from hook', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'))
expect(result.current.plugins).toBeDefined()
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('collection-1'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
})
// ================================
// useMarketplacePlugins Tests
// ================================
describe('useMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
})
it('should return initial state correctly', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.plugins).toBeUndefined()
expect(result.current.total).toBeUndefined()
expect(result.current.isLoading).toBe(false)
@@ -259,39 +200,21 @@ describe('useMarketplacePlugins', () => {
expect(result.current.page).toBe(0)
})
it('should provide queryPlugins function', async () => {
it('should expose all required functions', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(typeof result.current.queryPlugins).toBe('function')
})
it('should provide queryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.queryPluginsWithDebounced).toBe('function')
})
it('should provide cancelQueryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function')
})
it('should provide resetPlugins function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.resetPlugins).toBe('function')
})
it('should provide fetchNextPage function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.fetchNextPage).toBe('function')
})
it('should handle queryPlugins call without errors', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.queryPlugins({
query: 'test',
@@ -305,7 +228,8 @@ describe('useMarketplacePlugins', () => {
it('should handle queryPlugins with bundle type', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.queryPlugins({
query: 'test',
@@ -317,7 +241,8 @@ describe('useMarketplacePlugins', () => {
it('should handle resetPlugins call', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.resetPlugins()
}).not.toThrow()
@@ -325,18 +250,28 @@ describe('useMarketplacePlugins', () => {
it('should handle queryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
vi.useFakeTimers()
expect(() => {
result.current.queryPluginsWithDebounced({
query: 'debounced search',
category: 'all',
})
}).not.toThrow()
act(() => {
vi.advanceTimersByTime(500)
})
vi.useRealTimers()
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should handle cancelQueryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.cancelQueryPluginsWithDebounced()
}).not.toThrow()
@@ -344,13 +279,15 @@ describe('useMarketplacePlugins', () => {
it('should return correct page number', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.page).toBe(0)
})
it('should handle queryPlugins with tags', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.queryPlugins({
query: 'test',
@@ -361,60 +298,76 @@ describe('useMarketplacePlugins', () => {
})
})
// ================================
// Hooks queryFn Coverage Tests
// ================================
describe('Hooks queryFn Coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
mockPostMarketplaceShouldFail = false
capturedInfiniteQueryFn = null
capturedQueryFn = null
})
it('should cover queryFn with pages data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 },
],
}
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
category: 'tool',
})
expect(result.current).toBeDefined()
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should expose page and total from infinite query data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 },
{ plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 },
],
}
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace)
.mockResolvedValueOnce({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
],
total: 100,
},
})
.mockResolvedValueOnce({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'plugin3', tags: [] }],
total: 100,
},
})
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'search' })
expect(result.current.page).toBe(2)
result.current.queryPlugins({ query: 'search', page_size: 40 })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
expect(result.current.page).toBe(1)
expect(result.current.hasNextPage).toBe(true)
})
await act(async () => {
await result.current.fetchNextPage()
})
await waitFor(() => {
expect(result.current.page).toBe(2)
})
})
it('should return undefined total when no query is set', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.total).toBeUndefined()
})
it('should directly test queryFn execution', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'direct test',
@@ -424,82 +377,98 @@ describe('Hooks queryFn Coverage', () => {
page_size: 40,
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
}
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should test queryFn with bundle type', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
type: 'bundle',
query: 'bundle test',
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal })
expect(response).toBeDefined()
}
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should test queryFn error handling', async () => {
mockPostMarketplaceShouldFail = true
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'test that will fail' })
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
expect(response).toHaveProperty('plugins')
}
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins).toEqual([])
expect(result.current.total).toBe(0)
mockPostMarketplaceShouldFail = false
})
it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
result.current.queryMarketplaceCollectionsAndPlugins({
condition: 'category=tool',
})
if (capturedQueryFn) {
const controller = new AbortController()
const response = await capturedQueryFn({ signal: controller.signal })
expect(response).toBeDefined()
}
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
})
it('should test getNextPageParam directly', async () => {
it('should test getNextPageParam via fetchNextPage behavior', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace)
.mockResolvedValueOnce({
data: { plugins: [], total: 100 },
})
.mockResolvedValueOnce({
data: { plugins: [], total: 100 },
})
.mockResolvedValueOnce({
data: { plugins: [], total: 100 },
})
const { useMarketplacePlugins } = await import('../hooks')
renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
if (capturedGetNextPageParam) {
const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 })
expect(nextPage).toBe(2)
result.current.queryPlugins({ query: 'test', page_size: 40 })
const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 })
expect(noMorePages).toBeUndefined()
await waitFor(() => {
expect(result.current.hasNextPage).toBe(true)
expect(result.current.page).toBe(1)
})
const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 })
expect(atBoundary).toBeUndefined()
}
result.current.fetchNextPage()
await waitFor(() => {
expect(result.current.hasNextPage).toBe(true)
expect(result.current.page).toBe(2)
})
result.current.fetchNextPage()
await waitFor(() => {
expect(result.current.hasNextPage).toBe(false)
expect(result.current.page).toBe(3)
})
})
})
// ================================
// useMarketplaceContainerScroll Tests
// ================================
describe('useMarketplaceContainerScroll', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -0,0 +1,122 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
},
}))
let serverQueryClient: QueryClient
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => serverQueryClient,
}))
describe('HydrateQueryClient', () => {
beforeEach(() => {
vi.clearAllMocks()
serverQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
mockCollections.mockResolvedValue({
data: { collections: [] },
})
mockCollectionPlugins.mockResolvedValue({
data: { plugins: [] },
})
})
it('should render children within HydrationBoundary', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
const element = await HydrateQueryClient({
searchParams: undefined,
children: <div data-testid="child">Child Content</div>,
})
const renderClient = new QueryClient()
const { getByText } = render(
<QueryClientProvider client={renderClient}>
{element as React.ReactElement}
</QueryClientProvider>,
)
expect(getByText('Child Content')).toBeInTheDocument()
})
it('should not prefetch when searchParams is undefined', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: undefined,
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
})
it('should prefetch when category has collections (all)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'all' }),
children: <div>Child</div>,
})
expect(mockCollections).toHaveBeenCalled()
})
it('should prefetch when category has collections (tool)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'tool' }),
children: <div>Child</div>,
})
expect(mockCollections).toHaveBeenCalled()
})
it('should not prefetch when category does not have collections (model)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'model' }),
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
})
it('should not prefetch when category does not have collections (bundle)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'bundle' }),
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
})
})

View File

@@ -1,15 +1,95 @@
import { describe, it } from 'vitest'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The Marketplace index component is an async Server Component
// that cannot be unit tested in jsdom. It is covered by integration tests.
//
// All sub-module tests have been moved to dedicated spec files:
// - constants.spec.ts (DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD, PLUGIN_TYPE_SEARCH_MAP)
// - utils.spec.ts (getPluginIconInMarketplace, getFormattedPlugin, getPluginLinkInMarketplace, etc.)
// - hooks.spec.tsx (useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceContainerScroll)
vi.mock('@/context/query-client', () => ({
TanstackQueryInitializer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tanstack-initializer">{children}</div>
),
}))
describe('Marketplace index', () => {
it('should be covered by dedicated sub-module specs', () => {
// Placeholder to document the split
vi.mock('../hydration-server', () => ({
HydrateQueryClient: ({ children }: { children: React.ReactNode }) => (
<div data-testid="hydration-client">{children}</div>
),
}))
vi.mock('../description', () => ({
default: () => <div data-testid="description">Description</div>,
}))
vi.mock('../list/list-wrapper', () => ({
default: ({ showInstallButton }: { showInstallButton: boolean }) => (
<div data-testid="list-wrapper" data-show-install={showInstallButton}>ListWrapper</div>
),
}))
vi.mock('../sticky-search-and-switch-wrapper', () => ({
default: ({ pluginTypeSwitchClassName }: { pluginTypeSwitchClassName?: string }) => (
<div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}>StickyWrapper</div>
),
}))
describe('Marketplace', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should export a default async component', async () => {
const mod = await import('../index')
expect(mod.default).toBeDefined()
expect(typeof mod.default).toBe('function')
})
it('should render all child components with default props', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
expect(getByTestId('tanstack-initializer')).toBeInTheDocument()
expect(getByTestId('hydration-client')).toBeInTheDocument()
expect(getByTestId('description')).toBeInTheDocument()
expect(getByTestId('sticky-wrapper')).toBeInTheDocument()
expect(getByTestId('list-wrapper')).toBeInTheDocument()
})
it('should pass showInstallButton=true by default to ListWrapper', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
const listWrapper = getByTestId('list-wrapper')
expect(listWrapper.getAttribute('data-show-install')).toBe('true')
})
it('should pass showInstallButton=false when specified', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({ showInstallButton: false })
const { getByTestId } = render(element as React.ReactElement)
const listWrapper = getByTestId('list-wrapper')
expect(listWrapper.getAttribute('data-show-install')).toBe('false')
})
it('should pass pluginTypeSwitchClassName to StickySearchAndSwitchWrapper', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({ pluginTypeSwitchClassName: 'top-14' })
const { getByTestId } = render(element as React.ReactElement)
const stickyWrapper = getByTestId('sticky-wrapper')
expect(stickyWrapper.getAttribute('data-classname')).toBe('top-14')
})
it('should render without pluginTypeSwitchClassName', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
const stickyWrapper = getByTestId('sticky-wrapper')
expect(stickyWrapper.getAttribute('data-classname')).toBeNull()
})
})

View File

@@ -0,0 +1,124 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginTypeSwitch from '../plugin-type-switch'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'category.all': 'All',
'category.models': 'Models',
'category.tools': 'Tools',
'category.datasources': 'Data Sources',
'category.triggers': 'Triggers',
'category.agents': 'Agents',
'category.extensions': 'Extensions',
'category.bundles': 'Bundles',
}
return map[key] || key
},
}),
}))
const createWrapper = (searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
return { Wrapper, onUrlUpdate }
}
describe('PluginTypeSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all category options', () => {
const { Wrapper } = createWrapper()
render(<PluginTypeSwitch />, { wrapper: Wrapper })
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Data Sources')).toBeInTheDocument()
expect(screen.getByText('Triggers')).toBeInTheDocument()
expect(screen.getByText('Agents')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should apply active styling to current category', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
const allButton = screen.getByText('All').closest('div')
expect(allButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
it('should apply custom className', () => {
const { Wrapper } = createWrapper()
const { container } = render(<PluginTypeSwitch className="custom-class" />, { wrapper: Wrapper })
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('custom-class')
})
it('should update category when option is clicked', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on Models option — should not throw
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
})
it('should handle clicking on category with collections (Tools)', () => {
const { Wrapper } = createWrapper('?category=model')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on "Tools" which has collections → setSearchMode(null)
expect(() => fireEvent.click(screen.getByText('Tools'))).not.toThrow()
})
it('should handle clicking on category without collections (Models)', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on "Models" which does NOT have collections → no setSearchMode call
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
})
it('should handle clicking on bundles', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
expect(() => fireEvent.click(screen.getByText('Bundles'))).not.toThrow()
})
it('should handle clicking on each category', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles']
categories.forEach((category) => {
expect(() => fireEvent.click(screen.getByText(category))).not.toThrow()
})
})
it('should render icons for categories that have them', () => {
const { Wrapper } = createWrapper()
const { container } = render(<PluginTypeSwitch />, { wrapper: Wrapper })
// "All" has no icon (icon: null), others should have SVG icons
const svgs = container.querySelectorAll('svg')
// 7 categories with icons (all categories except "All")
expect(svgs.length).toBeGreaterThanOrEqual(7)
})
})

View File

@@ -0,0 +1,220 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
},
},
}))
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should fetch collections and plugins data', async () => {
const mockCollectionData = [
{ name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
]
const mockPluginData = [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
]
mockCollections.mockResolvedValue({ data: { collections: mockCollectionData } })
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
const { useMarketplaceCollectionsAndPlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin' }),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
expect(result.current.data?.marketplaceCollections).toBeDefined()
expect(result.current.data?.marketplaceCollectionPluginsMap).toBeDefined()
})
it('should handle empty collections params', async () => {
mockCollections.mockResolvedValue({ data: { collections: [] } })
const { useMarketplaceCollectionsAndPlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplaceCollectionsAndPlugins({}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
})
describe('useMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should not fetch when queryParams is undefined', async () => {
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins(undefined),
{ wrapper: Wrapper },
)
// enabled is false, so should not fetch
expect(result.current.data).toBeUndefined()
expect(mockSearchAdvanced).not.toHaveBeenCalled()
})
it('should fetch plugins when queryParams is provided', async () => {
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
total: 1,
},
})
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'test',
sort_by: 'install_count',
sort_order: 'DESC',
category: 'tool',
tags: [],
type: 'plugin',
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
expect(result.current.data?.pages).toHaveLength(1)
expect(result.current.data?.pages[0].plugins).toHaveLength(1)
})
it('should handle bundle type in query params', async () => {
mockSearchAdvanced.mockResolvedValue({
data: {
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [] }],
total: 1,
},
})
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'bundle',
type: 'bundle',
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
})
it('should handle API error gracefully', async () => {
mockSearchAdvanced.mockRejectedValue(new Error('Network error'))
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'fail',
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
expect(result.current.data?.pages[0].plugins).toEqual([])
expect(result.current.data?.pages[0].total).toBe(0)
})
it('should determine next page correctly via getNextPageParam', async () => {
// Return enough data that there would be a next page
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, i) => ({
type: 'plugin',
org: 'test',
name: `p${i}`,
tags: [],
})),
total: 100,
},
})
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'paginated',
page_size: 40,
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.hasNextPage).toBe(true)
})
})
})

View File

@@ -0,0 +1,267 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
},
},
}))
const createWrapper = (searchParams = '') => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
</JotaiProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplaceData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollections.mockResolvedValue({
data: {
collections: [
{ name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
],
},
})
mockCollectionPlugins.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
},
})
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p2', tags: [] }],
total: 1,
},
})
})
it('should return initial state with loading and collections data', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=all')
// Create a mock container for scroll
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
expect(result.current.page).toBeDefined()
expect(result.current.isFetchingNextPage).toBe(false)
document.body.removeChild(container)
})
it('should return search mode data when search text is present', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=all&q=test')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.plugins).toBeDefined()
expect(result.current.pluginsTotal).toBeDefined()
document.body.removeChild(container)
})
it('should return plugins undefined in collection mode (not search mode)', async () => {
const { useMarketplaceData } = await import('../state')
// "all" category with no search → collection mode
const { Wrapper } = createWrapper('?category=all')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// In non-search mode, plugins should be undefined since useMarketplacePlugins is disabled
expect(result.current.plugins).toBeUndefined()
document.body.removeChild(container)
})
it('should enable search for category without collections (e.g. model)', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// "model" triggers search mode automatically
expect(result.current.plugins).toBeDefined()
document.body.removeChild(container)
})
it('should trigger scroll pagination via handlePageChange callback', async () => {
// Return enough data to indicate hasNextPage (40 of 200 total)
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, i) => ({
type: 'plugin',
org: 'test',
name: `p${i}`,
tags: [],
})),
total: 200,
},
})
const { useMarketplaceData } = await import('../state')
// Use "model" to force search mode
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true })
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
// Wait for data to fully load (isFetching becomes false, plugins become available)
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
expect(result.current.plugins!.length).toBeGreaterThan(0)
})
// Trigger scroll event to invoke handlePageChange
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: container })
container.dispatchEvent(scrollEvent)
document.body.removeChild(container)
})
it('should handle tags filter in search mode', async () => {
const { useMarketplaceData } = await import('../state')
// tags in URL triggers search mode
const { Wrapper } = createWrapper('?category=all&tags=search')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// Tags triggers search mode even with "all" category
expect(result.current.plugins).toBeDefined()
document.body.removeChild(container)
})
it('should not fetch next page when scroll fires but no more data', async () => {
// Return only 2 items with total=2 → no more pages
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'p1', tags: [] },
{ type: 'plugin', org: 'test', name: 'p2', tags: [] },
],
total: 2,
},
})
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true })
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
// Scroll fires but hasNextPage is false → handlePageChange does nothing
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: container })
container.dispatchEvent(scrollEvent)
// isFetchingNextPage should remain false
expect(result.current.isFetchingNextPage).toBe(false)
document.body.removeChild(container)
})
})

View File

@@ -0,0 +1,79 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock child components to isolate wrapper logic
vi.mock('../plugin-type-switch', () => ({
default: () => <div data-testid="plugin-type-switch">PluginTypeSwitch</div>,
}))
vi.mock('../search-box/search-box-wrapper', () => ({
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
}))
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
describe('StickySearchAndSwitchWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render SearchBoxWrapper and PluginTypeSwitch', () => {
const { getByTestId } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
)
expect(getByTestId('search-box-wrapper')).toBeInTheDocument()
expect(getByTestId('plugin-type-switch')).toBeInTheDocument()
})
it('should not apply sticky class when no pluginTypeSwitchClassName', () => {
const { container } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('mt-4')
expect(outerDiv.className).not.toContain('sticky')
})
it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => {
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('sticky')
expect(outerDiv.className).toContain('z-10')
expect(outerDiv.className).toContain('top-10')
})
it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => {
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).not.toContain('sticky')
expect(outerDiv.className).toContain('custom-class')
})
})

View File

@@ -315,3 +315,165 @@ describe('getCollectionsParams', () => {
})
})
})
describe('getMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty result when queryParams is undefined', async () => {
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins(undefined, 1)
expect(result).toEqual({
plugins: [],
total: 0,
page: 1,
page_size: 40,
})
expect(mockSearchAdvanced).not.toHaveBeenCalled()
})
it('should fetch plugins with valid query params', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
total: 1,
},
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({
query: 'test',
sort_by: 'install_count',
sort_order: 'DESC',
category: 'tool',
tags: ['search'],
type: 'plugin',
page_size: 20,
}, 1)
expect(result.plugins).toHaveLength(1)
expect(result.total).toBe(1)
expect(result.page).toBe(1)
expect(result.page_size).toBe(20)
})
it('should use bundles endpoint when type is bundle', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: {
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }],
total: 1,
},
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({
query: 'bundle',
type: 'bundle',
}, 1)
expect(result.plugins).toHaveLength(1)
const call = mockSearchAdvanced.mock.calls[0]
expect(call[0].params.kind).toBe('bundles')
})
it('should use empty category when category is all', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: { plugins: [], total: 0 },
})
const { getMarketplacePlugins } = await import('../utils')
await getMarketplacePlugins({
query: 'test',
category: 'all',
}, 1)
const call = mockSearchAdvanced.mock.calls[0]
expect(call[0].body.category).toBe('')
})
it('should handle API error and return empty result', async () => {
mockSearchAdvanced.mockRejectedValueOnce(new Error('API error'))
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({
query: 'fail',
}, 2)
expect(result).toEqual({
plugins: [],
total: 0,
page: 2,
page_size: 40,
})
})
it('should pass abort signal when provided', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: { plugins: [], total: 0 },
})
const controller = new AbortController()
const { getMarketplacePlugins } = await import('../utils')
await getMarketplacePlugins({ query: 'test' }, 1, controller.signal)
const call = mockSearchAdvanced.mock.calls[0]
expect(call[1]).toMatchObject({ signal: controller.signal })
})
it('should default page_size to 40 when not provided', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: { plugins: [], total: 0 },
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({ query: 'test' }, 1)
expect(result.page_size).toBe(40)
})
it('should handle response with bundles fallback to plugins fallback to empty', async () => {
// No bundles and no plugins in response
mockSearchAdvanced.mockResolvedValueOnce({
data: { total: 0 },
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({ query: 'test' }, 1)
expect(result.plugins).toEqual([])
})
})
// ================================
// Edge cases for ||/optional chaining branches
// ================================
describe('Utils branch edge cases', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle collectionPlugins returning undefined plugins', async () => {
mockCollectionPlugins.mockResolvedValueOnce({
data: { plugins: undefined },
})
const { getMarketplacePluginsByCollectionId } = await import('../utils')
const result = await getMarketplacePluginsByCollectionId('test-collection')
expect(result).toEqual([])
})
it('should handle collections returning undefined collections list', async () => {
mockCollections.mockResolvedValueOnce({
data: { collections: undefined },
})
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
const result = await getMarketplaceCollectionsAndPlugins()
// undefined || [] evaluates to [], so empty array is expected
expect(result.marketplaceCollections).toEqual([])
})
})

View File

@@ -1,597 +0,0 @@
import { render, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n-config/i18next-config', () => ({
default: {
getFixedT: () => (key: string) => key,
},
}))
const mockSetUrlFilters = vi.fn()
vi.mock('@/hooks/use-query-params', () => ({
useMarketplaceFilters: () => [
{ q: '', tags: [], category: '' },
mockSetUrlFilters,
],
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
isSuccess: true,
}),
}))
const mockFetchNextPage = vi.fn()
const mockHasNextPage = false
let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined
let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => {
capturedQueryFn = queryFn
if (queryFn) {
const controller = new AbortController()
queryFn({ signal: controller.signal }).catch(() => {})
}
return {
data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined,
isFetching: false,
isPending: false,
isSuccess: enabled,
}
}),
useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: {
queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>
getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined
enabled: boolean
}) => {
capturedInfiniteQueryFn = queryFn
capturedGetNextPageParam = getNextPageParam
if (queryFn) {
const controller = new AbortController()
queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {})
}
if (getNextPageParam) {
getNextPageParam({ page: 1, page_size: 40, total: 100 })
getNextPageParam({ page: 3, page_size: 40, total: 100 })
}
return {
data: mockInfiniteQueryData,
isPending: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: mockHasNextPage,
fetchNextPage: mockFetchNextPage,
}
}),
useQueryClient: vi.fn(() => ({
removeQueries: vi.fn(),
})),
}))
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: fn,
cancel: vi.fn(),
}),
}))
let mockPostMarketplaceShouldFail = false
const mockPostMarketplaceResponse = {
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
],
bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>,
total: 2,
},
}
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(() => {
if (mockPostMarketplaceShouldFail)
return Promise.reject(new Error('Mock API error'))
return Promise.resolve(mockPostMarketplaceResponse)
}),
}))
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: vi.fn(async () => ({
data: {
collections: [
{
name: 'collection-1',
label: { 'en-US': 'Collection 1' },
description: { 'en-US': 'Desc' },
rule: '',
created_at: '2024-01-01',
updated_at: '2024-01-01',
searchable: true,
search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' },
},
],
},
})),
collectionPlugins: vi.fn(async () => ({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
},
})),
searchAdvanced: vi.fn(async () => ({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
total: 1,
},
})),
},
}))
// ================================
// useMarketplaceCollectionsAndPlugins Tests
// ================================
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return initial state correctly', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
expect(result.current.setMarketplaceCollections).toBeDefined()
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
})
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
})
it('should provide setMarketplaceCollections function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollections).toBe('function')
})
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
})
it('should return marketplaceCollections from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollections).toBeUndefined()
})
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
})
})
// ================================
// useMarketplacePluginsByCollectionId Tests
// ================================
describe('useMarketplacePluginsByCollectionId', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return initial state when collectionId is undefined', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined))
expect(result.current.plugins).toEqual([])
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
})
it('should return isLoading false when collectionId is provided and query completes', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'))
expect(result.current.isLoading).toBe(false)
})
it('should accept query parameter', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() =>
useMarketplacePluginsByCollectionId('test-collection', {
category: 'tool',
type: 'plugin',
}))
expect(result.current.plugins).toBeDefined()
})
it('should return plugins property from hook', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'))
expect(result.current.plugins).toBeDefined()
})
})
// ================================
// useMarketplacePlugins Tests
// ================================
describe('useMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
})
it('should return initial state correctly', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(result.current.plugins).toBeUndefined()
expect(result.current.total).toBeUndefined()
expect(result.current.isLoading).toBe(false)
expect(result.current.isFetchingNextPage).toBe(false)
expect(result.current.hasNextPage).toBe(false)
expect(result.current.page).toBe(0)
})
it('should provide queryPlugins function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.queryPlugins).toBe('function')
})
it('should provide queryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.queryPluginsWithDebounced).toBe('function')
})
it('should provide cancelQueryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function')
})
it('should provide resetPlugins function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.resetPlugins).toBe('function')
})
it('should provide fetchNextPage function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.fetchNextPage).toBe('function')
})
it('should handle queryPlugins call without errors', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPlugins({
query: 'test',
sort_by: 'install_count',
sort_order: 'DESC',
category: 'tool',
page_size: 20,
})
}).not.toThrow()
})
it('should handle queryPlugins with bundle type', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPlugins({
query: 'test',
type: 'bundle',
page_size: 40,
})
}).not.toThrow()
})
it('should handle resetPlugins call', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.resetPlugins()
}).not.toThrow()
})
it('should handle queryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPluginsWithDebounced({
query: 'debounced search',
category: 'all',
})
}).not.toThrow()
})
it('should handle cancelQueryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.cancelQueryPluginsWithDebounced()
}).not.toThrow()
})
it('should return correct page number', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(result.current.page).toBe(0)
})
it('should handle queryPlugins with tags', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPlugins({
query: 'test',
tags: ['search', 'image'],
exclude: ['excluded-plugin'],
})
}).not.toThrow()
})
})
// ================================
// Hooks queryFn Coverage Tests
// ================================
describe('Hooks queryFn Coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
mockPostMarketplaceShouldFail = false
capturedInfiniteQueryFn = null
capturedQueryFn = null
})
it('should cover queryFn with pages data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 },
],
}
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({
query: 'test',
category: 'tool',
})
expect(result.current).toBeDefined()
})
it('should expose page and total from infinite query data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 },
{ plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 },
],
}
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({ query: 'search' })
expect(result.current.page).toBe(2)
})
it('should return undefined total when no query is set', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(result.current.total).toBeUndefined()
})
it('should directly test queryFn execution', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({
query: 'direct test',
category: 'tool',
sort_by: 'install_count',
sort_order: 'DESC',
page_size: 40,
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
}
})
it('should test queryFn with bundle type', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({
type: 'bundle',
query: 'bundle test',
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal })
expect(response).toBeDefined()
}
})
it('should test queryFn error handling', async () => {
mockPostMarketplaceShouldFail = true
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({ query: 'test that will fail' })
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
expect(response).toHaveProperty('plugins')
}
mockPostMarketplaceShouldFail = false
})
it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
result.current.queryMarketplaceCollectionsAndPlugins({
condition: 'category=tool',
})
if (capturedQueryFn) {
const controller = new AbortController()
const response = await capturedQueryFn({ signal: controller.signal })
expect(response).toBeDefined()
}
})
it('should test getNextPageParam directly', async () => {
const { useMarketplacePlugins } = await import('./hooks')
renderHook(() => useMarketplacePlugins())
if (capturedGetNextPageParam) {
const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 })
expect(nextPage).toBe(2)
const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 })
expect(noMorePages).toBeUndefined()
const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 })
expect(atBoundary).toBeUndefined()
}
})
})
// ================================
// useMarketplaceContainerScroll Tests
// ================================
describe('useMarketplaceContainerScroll', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should attach scroll event listener to container', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'marketplace-container'
document.body.appendChild(mockContainer)
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback)
return null
}
render(<TestComponent />)
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
document.body.removeChild(mockContainer)
})
it('should call callback when scrolled to bottom', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'scroll-test-container-hooks'
document.body.appendChild(mockContainer)
Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true })
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks')
return null
}
render(<TestComponent />)
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
mockContainer.dispatchEvent(scrollEvent)
expect(mockCallback).toHaveBeenCalled()
document.body.removeChild(mockContainer)
})
it('should not call callback when scrollTop is 0', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'scroll-test-container-hooks-2'
document.body.appendChild(mockContainer)
Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true })
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks-2')
return null
}
render(<TestComponent />)
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
mockContainer.dispatchEvent(scrollEvent)
expect(mockCallback).not.toHaveBeenCalled()
document.body.removeChild(mockContainer)
})
it('should remove event listener on unmount', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'scroll-unmount-container-hooks'
document.body.appendChild(mockContainer)
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container-hooks')
return null
}
const { unmount } = render(<TestComponent />)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
document.body.removeChild(mockContainer)
})
})

View File

@@ -1,140 +1,7 @@
import type { ReactNode } from 'react'
import type { Credential, PluginPayload } from '../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { AuthCategory, CredentialTypeEnum } from '../types'
const mockGetPluginCredentialInfo = vi.fn()
const mockDeletePluginCredential = vi.fn()
const mockSetPluginDefaultCredential = vi.fn()
const mockUpdatePluginCredential = vi.fn()
const mockInvalidPluginCredentialInfo = vi.fn()
const mockGetPluginOAuthUrl = vi.fn()
const mockGetPluginOAuthClientSchema = vi.fn()
const mockSetPluginOAuthCustomClient = vi.fn()
const mockDeletePluginOAuthCustomClient = vi.fn()
const mockInvalidPluginOAuthClientSchema = vi.fn()
const mockAddPluginCredential = vi.fn()
const mockGetPluginCredentialSchema = vi.fn()
const mockInvalidToolsByType = vi.fn()
vi.mock('@/service/use-plugins-auth', () => ({
useGetPluginCredentialInfo: (url: string) => ({
data: url ? mockGetPluginCredentialInfo() : undefined,
isLoading: false,
}),
useDeletePluginCredential: () => ({
mutateAsync: mockDeletePluginCredential,
}),
useSetPluginDefaultCredential: () => ({
mutateAsync: mockSetPluginDefaultCredential,
}),
useUpdatePluginCredential: () => ({
mutateAsync: mockUpdatePluginCredential,
}),
useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo,
useGetPluginOAuthUrl: () => ({
mutateAsync: mockGetPluginOAuthUrl,
}),
useGetPluginOAuthClientSchema: () => ({
data: mockGetPluginOAuthClientSchema(),
isLoading: false,
}),
useSetPluginOAuthCustomClient: () => ({
mutateAsync: mockSetPluginOAuthCustomClient,
}),
useDeletePluginOAuthCustomClient: () => ({
mutateAsync: mockDeletePluginOAuthCustomClient,
}),
useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema,
useAddPluginCredential: () => ({
mutateAsync: mockAddPluginCredential,
}),
useGetPluginCredentialSchema: () => ({
data: mockGetPluginCredentialSchema(),
isLoading: false,
}),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidToolsByType: () => mockInvalidToolsByType,
}))
const mockIsCurrentWorkspaceManager = vi.fn()
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
}))
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => ({
data: { options: [] },
isLoading: false,
}),
useTriggerPluginDynamicOptionsInfo: () => ({
data: null,
isLoading: false,
}),
useInvalidTriggerDynamicOptions: () => vi.fn(),
}))
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
const _createWrapper = () => {
const testQueryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
)
}
const _createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
category: AuthCategory.tool,
provider: 'test-provider',
...overrides,
})
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
id: 'test-credential-id',
name: 'Test Credential',
provider: 'test-provider',
credential_type: CredentialTypeEnum.API_KEY,
is_default: false,
credentials: { api_key: 'test-key' },
...overrides,
})
const _createCredentialList = (count: number, overrides: Partial<Credential>[] = []): Credential[] => {
return Array.from({ length: count }, (_, i) => createCredential({
id: `credential-${i}`,
name: `Credential ${i}`,
is_default: i === 0,
...overrides[i],
}))
}
describe('Index Exports', () => {
describe('plugin-auth index exports', () => {
it('should export all required components and hooks', async () => {
const exports = await import('../index')
@@ -144,104 +11,23 @@ describe('Index Exports', () => {
expect(exports.Authorized).toBeDefined()
expect(exports.AuthorizedInDataSourceNode).toBeDefined()
expect(exports.AuthorizedInNode).toBeDefined()
expect(exports.usePluginAuth).toBeDefined()
expect(exports.PluginAuth).toBeDefined()
expect(exports.PluginAuthInAgent).toBeDefined()
expect(exports.PluginAuthInDataSourceNode).toBeDefined()
}, 15000)
it('should export AuthCategory enum', async () => {
const exports = await import('../index')
expect(exports.AuthCategory).toBeDefined()
expect(exports.AuthCategory.tool).toBe('tool')
expect(exports.AuthCategory.datasource).toBe('datasource')
expect(exports.AuthCategory.model).toBe('model')
expect(exports.AuthCategory.trigger).toBe('trigger')
}, 15000)
it('should export CredentialTypeEnum', async () => {
const exports = await import('../index')
expect(exports.CredentialTypeEnum).toBeDefined()
expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2')
expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key')
}, 15000)
})
describe('Types', () => {
describe('AuthCategory enum', () => {
it('should have correct values', () => {
expect(AuthCategory.tool).toBe('tool')
expect(AuthCategory.datasource).toBe('datasource')
expect(AuthCategory.model).toBe('model')
expect(AuthCategory.trigger).toBe('trigger')
})
it('should have exactly 4 categories', () => {
const values = Object.values(AuthCategory)
expect(values).toHaveLength(4)
})
expect(exports.usePluginAuth).toBeDefined()
})
describe('CredentialTypeEnum', () => {
it('should have correct values', () => {
expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
expect(CredentialTypeEnum.API_KEY).toBe('api-key')
})
it('should have exactly 2 types', () => {
const values = Object.values(CredentialTypeEnum)
expect(values).toHaveLength(2)
})
it('should re-export AuthCategory enum with correct values', () => {
expect(Object.values(AuthCategory)).toHaveLength(4)
expect(AuthCategory.tool).toBe('tool')
expect(AuthCategory.datasource).toBe('datasource')
expect(AuthCategory.model).toBe('model')
expect(AuthCategory.trigger).toBe('trigger')
})
describe('Credential type', () => {
it('should allow creating valid credentials', () => {
const credential: Credential = {
id: 'test-id',
name: 'Test',
provider: 'test-provider',
is_default: true,
}
expect(credential.id).toBe('test-id')
expect(credential.is_default).toBe(true)
})
it('should allow optional fields', () => {
const credential: Credential = {
id: 'test-id',
name: 'Test',
provider: 'test-provider',
is_default: false,
credential_type: CredentialTypeEnum.API_KEY,
credentials: { key: 'value' },
isWorkspaceDefault: true,
from_enterprise: false,
not_allowed_to_use: false,
}
expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY)
expect(credential.isWorkspaceDefault).toBe(true)
})
})
describe('PluginPayload type', () => {
it('should allow creating valid plugin payload', () => {
const payload: PluginPayload = {
category: AuthCategory.tool,
provider: 'test-provider',
}
expect(payload.category).toBe(AuthCategory.tool)
})
it('should allow optional fields', () => {
const payload: PluginPayload = {
category: AuthCategory.datasource,
provider: 'test-provider',
providerType: 'builtin',
detail: undefined,
}
expect(payload.providerType).toBe('builtin')
})
it('should re-export CredentialTypeEnum with correct values', () => {
expect(Object.values(CredentialTypeEnum)).toHaveLength(2)
expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
expect(CredentialTypeEnum.API_KEY).toBe('api-key')
})
})

View File

@@ -92,7 +92,7 @@ describe('PluginAuth', () => {
expect(screen.queryByTestId('authorized')).not.toBeInTheDocument()
})
it('applies className when not authorized', () => {
it('renders with className wrapper when not authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
@@ -104,10 +104,10 @@ describe('PluginAuth', () => {
})
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
expect((container.firstChild as HTMLElement).className).toContain('custom-class')
expect(container.innerHTML).toContain('custom-class')
})
it('does not apply className when authorized', () => {
it('does not render className wrapper when authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
@@ -119,7 +119,7 @@ describe('PluginAuth', () => {
})
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
expect((container.firstChild as HTMLElement).className).not.toContain('custom-class')
expect(container.innerHTML).not.toContain('custom-class')
})
it('passes pluginPayload.provider to usePluginAuth', () => {

View File

@@ -96,7 +96,7 @@ describe('Authorize', () => {
it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
const pluginPayload = createPluginPayload()
const { container } = render(
render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={false}
@@ -105,10 +105,7 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
// No buttons should be rendered
expect(screen.queryByRole('button')).not.toBeInTheDocument()
// Container should only have wrapper element
expect(container.querySelector('.flex')).toBeInTheDocument()
})
it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
@@ -225,7 +222,7 @@ describe('Authorize', () => {
// ==================== Props Testing ====================
describe('Props Testing', () => {
describe('theme prop', () => {
it('should render buttons with secondary theme variant when theme is secondary', () => {
it('should render buttons when theme is secondary', () => {
const pluginPayload = createPluginPayload()
render(
@@ -239,9 +236,7 @@ describe('Authorize', () => {
)
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button.className).toContain('btn-secondary')
})
expect(buttons).toHaveLength(2)
})
})
@@ -327,10 +322,10 @@ describe('Authorize', () => {
expect(screen.getByRole('button')).toBeDisabled()
})
it('should add opacity class when notAllowCustomCredential is true', () => {
it('should disable all buttons when notAllowCustomCredential is true', () => {
const pluginPayload = createPluginPayload()
const { container } = render(
render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
@@ -340,8 +335,8 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
const wrappers = container.querySelectorAll('.opacity-50')
expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
const buttons = screen.getAllByRole('button')
buttons.forEach(button => expect(button).toBeDisabled())
})
})
})
@@ -459,7 +454,7 @@ describe('Authorize', () => {
expect(screen.getAllByRole('button').length).toBe(2)
})
it('should update button variant when theme changes', () => {
it('should change button styling when theme changes', () => {
const pluginPayload = createPluginPayload()
const { rerender } = render(
@@ -471,9 +466,7 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
const buttonPrimary = screen.getByRole('button')
// Primary theme with canOAuth=false should have primary variant
expect(buttonPrimary.className).toContain('btn-primary')
const primaryClassName = screen.getByRole('button').className
rerender(
<Authorize
@@ -483,7 +476,8 @@ describe('Authorize', () => {
/>,
)
expect(screen.getByRole('button').className).toContain('btn-secondary')
const secondaryClassName = screen.getByRole('button').className
expect(primaryClassName).not.toBe(secondaryClassName)
})
})
@@ -574,38 +568,10 @@ describe('Authorize', () => {
expect(typeof AuthorizeDefault).toBe('object')
})
it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
const pluginPayload = createPluginPayload()
const onUpdate = vi.fn()
const { rerender, container } = render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
notAllowCustomCredential={false}
onUpdate={onUpdate}
/>,
{ wrapper: createWrapper() },
)
const initialOpacityElements = container.querySelectorAll('.opacity-50').length
rerender(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
notAllowCustomCredential={false}
onUpdate={onUpdate}
/>,
)
expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
})
it('should update wrapper when notAllowCustomCredential changes', () => {
it('should reflect notAllowCustomCredential change via button disabled state', () => {
const pluginPayload = createPluginPayload()
const { rerender, container } = render(
const { rerender } = render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
@@ -614,7 +580,7 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
expect(container.querySelectorAll('.opacity-50').length).toBe(0)
expect(screen.getByRole('button')).not.toBeDisabled()
rerender(
<Authorize
@@ -624,7 +590,7 @@ describe('Authorize', () => {
/>,
)
expect(container.querySelectorAll('.opacity-50').length).toBe(1)
expect(screen.getByRole('button')).toBeDisabled()
})
})

View File

@@ -1,5 +1,5 @@
import type { Credential } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '../../types'
import Item from '../item'
@@ -67,7 +67,7 @@ describe('Item Component', () => {
it('should render selected icon when showSelectedIcon is true and credential is selected', () => {
const credential = createCredential({ id: 'selected-id' })
render(
const { container } = render(
<Item
credential={credential}
showSelectedIcon={true}
@@ -75,53 +75,64 @@ describe('Item Component', () => {
/>,
)
// RiCheckLine should be rendered
expect(document.querySelector('.text-text-accent')).toBeInTheDocument()
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should not render selected icon when credential is not selected', () => {
const credential = createCredential({ id: 'not-selected-id' })
render(
const { container: selectedContainer } = render(
<Item
credential={createCredential({ id: 'sel-id' })}
showSelectedIcon={true}
selectedCredentialId="sel-id"
/>,
)
const selectedSvgCount = selectedContainer.querySelectorAll('svg').length
cleanup()
const { container: unselectedContainer } = render(
<Item
credential={credential}
showSelectedIcon={true}
selectedCredentialId="other-id"
/>,
)
const unselectedSvgCount = unselectedContainer.querySelectorAll('svg').length
// Check icon should not be visible
expect(document.querySelector('.text-text-accent')).not.toBeInTheDocument()
expect(unselectedSvgCount).toBeLessThan(selectedSvgCount)
})
it('should render with gray indicator when not_allowed_to_use is true', () => {
it('should render with disabled appearance when not_allowed_to_use is true', () => {
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
// The item should have tooltip wrapper with data-state attribute for unavailable credential
const tooltipTrigger = container.querySelector('[data-state]')
expect(tooltipTrigger).toBeInTheDocument()
// The item should have disabled styles
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
it('should apply disabled styles when disabled is true', () => {
it('should not call onItemClick when disabled is true', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(<Item credential={credential} disabled={true} />)
const { container } = render(<Item credential={credential} onItemClick={onItemClick} disabled={true} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
fireEvent.click(container.firstElementChild!)
expect(onItemClick).not.toHaveBeenCalled()
})
it('should apply disabled styles when not_allowed_to_use is true', () => {
it('should not call onItemClick when not_allowed_to_use is true', () => {
const onItemClick = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
const { container } = render(<Item credential={credential} onItemClick={onItemClick} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
fireEvent.click(container.firstElementChild!)
expect(onItemClick).not.toHaveBeenCalled()
})
})
@@ -135,8 +146,7 @@ describe('Item Component', () => {
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
fireEvent.click(container.firstElementChild!)
expect(onItemClick).toHaveBeenCalledWith('click-test-id')
})
@@ -149,49 +159,22 @@ describe('Item Component', () => {
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
fireEvent.click(container.firstElementChild!)
expect(onItemClick).toHaveBeenCalledWith('')
})
it('should not call onItemClick when disabled', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} disabled={true} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onItemClick when not_allowed_to_use is true', () => {
const onItemClick = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
})
// ==================== Rename Mode Tests ====================
describe('Rename Mode', () => {
it('should enter rename mode when rename button is clicked', () => {
const credential = createCredential()
const renderWithRenameEnabled = (overrides: Record<string, unknown> = {}) => {
const onRename = vi.fn()
const credential = createCredential({ name: 'Original Name', ...overrides })
const { container } = render(
const result = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
@@ -199,224 +182,67 @@ describe('Item Component', () => {
/>,
)
// Since buttons are hidden initially, we need to find the ActionButton
// In the actual implementation, they are rendered but hidden
const actionButtons = container.querySelectorAll('button')
const renameBtn = Array.from(actionButtons).find(btn =>
btn.querySelector('.ri-edit-line') || btn.innerHTML.includes('RiEditLine'),
)
if (renameBtn) {
fireEvent.click(renameBtn)
// Should show input for rename
expect(screen.getByRole('textbox')).toBeInTheDocument()
const enterRenameMode = () => {
const firstButton = result.container.querySelectorAll('button')[0] as HTMLElement
fireEvent.click(firstButton)
}
return { ...result, onRename, enterRenameMode }
}
it('should enter rename mode when rename button is clicked', () => {
const { enterRenameMode } = renderWithRenameEnabled()
enterRenameMode()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should show save and cancel buttons in rename mode', () => {
const onRename = vi.fn()
const credential = createCredential({ name: 'Original Name' })
const { enterRenameMode } = renderWithRenameEnabled()
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
// Find and click rename button to enter rename mode
const actionButtons = container.querySelectorAll('button')
// Find the rename action button by looking for RiEditLine icon
actionButtons.forEach((btn) => {
if (btn.querySelector('svg')) {
fireEvent.click(btn)
}
})
// If we're in rename mode, there should be save/cancel buttons
const buttons = screen.queryAllByRole('button')
if (buttons.length >= 2) {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
}
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
})
it('should call onRename with new name when save is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-test-id', name: 'Original' })
const { enterRenameMode, onRename } = renderWithRenameEnabled({ id: 'rename-test-id' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
// Trigger rename mode by clicking the rename button
const editIcon = container.querySelector('svg.ri-edit-line')
if (editIcon) {
fireEvent.click(editIcon.closest('button')!)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Now in rename mode, change input and save
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
// Click save
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-test-id',
name: 'New Name',
})
}
})
it('should call onRename and exit rename mode when save button is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-save-test', name: 'Original Name' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find and click rename button to enter rename mode
// The button contains RiEditLine svg
const allButtons = Array.from(container.querySelectorAll('button'))
let renameButton: Element | null = null
for (const btn of allButtons) {
if (btn.querySelector('svg')) {
renameButton = btn
break
}
}
if (renameButton) {
fireEvent.click(renameButton)
// Should be in rename mode now
const input = screen.queryByRole('textbox')
if (input) {
expect(input).toHaveValue('Original Name')
// Change the value
fireEvent.change(input, { target: { value: 'Updated Name' } })
expect(input).toHaveValue('Updated Name')
// Click save button
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
// Verify onRename was called with correct parameters
expect(onRename).toHaveBeenCalledTimes(1)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-save-test',
name: 'Updated Name',
})
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-test-id',
name: 'New Name',
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should exit rename mode when cancel is clicked', () => {
const credential = createCredential({ name: 'Original' })
const { enterRenameMode } = renderWithRenameEnabled()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
expect(screen.getByRole('textbox')).toBeInTheDocument()
// Enter rename mode
const editIcon = container.querySelector('svg')?.closest('button')
if (editIcon) {
fireEvent.click(editIcon)
fireEvent.click(screen.getByText('common.operation.cancel'))
// If in rename mode, cancel button should exist
const cancelButton = screen.queryByText('common.operation.cancel')
if (cancelButton) {
fireEvent.click(cancelButton)
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should update rename value when input changes', () => {
const credential = createCredential({ name: 'Original' })
it('should update input value when typing', () => {
const { enterRenameMode } = renderWithRenameEnabled()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
// We need to get into rename mode first
// The rename button appears on hover in the actions area
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Updated Value' } })
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.change(input, { target: { value: 'Updated Value' } })
expect(input).toHaveValue('Updated Value')
}
}
})
it('should stop propagation when clicking input in rename mode', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
onItemClick={onItemClick}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Enter rename mode and click on input
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.click(input)
// onItemClick should not be called when clicking the input
expect(onItemClick).not.toHaveBeenCalled()
}
}
expect(input).toHaveValue('Updated Value')
})
})
@@ -437,12 +263,9 @@ describe('Item Component', () => {
/>,
)
// Find set default button
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
fireEvent.click(setDefaultButton)
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
}
const setDefaultButton = screen.getByText('plugin.auth.setDefault')
fireEvent.click(setDefaultButton)
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
})
it('should not show set default button when credential is already default', () => {
@@ -517,16 +340,13 @@ describe('Item Component', () => {
/>,
)
// Find the edit button (RiEqualizer2Line icon)
const editButton = container.querySelector('svg')?.closest('button')
if (editButton) {
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
api_key: 'secret',
__name__: 'Edit Test',
__credential_id__: 'edit-test-id',
})
}
const editButton = container.querySelector('svg')?.closest('button') as HTMLElement
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
api_key: 'secret',
__name__: 'Edit Test',
__credential_id__: 'edit-test-id',
})
})
it('should not show edit button for OAuth credentials', () => {
@@ -584,12 +404,9 @@ describe('Item Component', () => {
/>,
)
// Find delete button (RiDeleteBinLine icon)
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
}
const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
})
it('should not show delete button when disableDelete is true', () => {
@@ -704,44 +521,15 @@ describe('Item Component', () => {
/>,
)
// Find delete button and click
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
// onDelete should be called but not onItemClick (due to stopPropagation)
expect(onDelete).toHaveBeenCalled()
// Note: onItemClick might still be called due to event bubbling in test environment
}
})
it('should disable action buttons when disabled prop is true', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disabled={true}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
// Set default button should be disabled
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
const button = setDefaultButton.closest('button')
expect(button).toBeDisabled()
}
const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalled()
})
})
// ==================== showAction Logic Tests ====================
describe('Show Action Logic', () => {
it('should not show action area when all actions are disabled', () => {
it('should not render action buttons when all actions are disabled', () => {
const credential = createCredential()
const { container } = render(
@@ -754,12 +542,10 @@ describe('Item Component', () => {
/>,
)
// Should not have action area with hover:flex
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).not.toBeInTheDocument()
expect(container.querySelectorAll('button').length).toBe(0)
})
it('should show action area when at least one action is enabled', () => {
it('should render action buttons when at least one action is enabled', () => {
const credential = createCredential()
const { container } = render(
@@ -772,38 +558,33 @@ describe('Item Component', () => {
/>,
)
// Should have action area
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).toBeInTheDocument()
expect(container.querySelectorAll('button').length).toBeGreaterThan(0)
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle credential with empty name', () => {
const credential = createCredential({ name: '' })
render(<Item credential={credential} />)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
expect(() => {
render(<Item credential={credential} />)
}).not.toThrow()
})
it('should handle credential with undefined credentials object', () => {
const credential = createCredential({ credentials: undefined })
render(
<Item
credential={credential}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
expect(() => {
render(
<Item
credential={credential}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
}).not.toThrow()
})
it('should handle all optional callbacks being undefined', () => {
@@ -814,13 +595,13 @@ describe('Item Component', () => {
}).not.toThrow()
})
it('should properly display long credential names with truncation', () => {
it('should display long credential names with title attribute', () => {
const longName = 'A'.repeat(100)
const credential = createCredential({ name: longName })
const { container } = render(<Item credential={credential} />)
const nameElement = container.querySelector('.truncate')
const nameElement = container.querySelector('[title]')
expect(nameElement).toBeInTheDocument()
expect(nameElement?.getAttribute('title')).toBe(longName)
})

View File

@@ -4,10 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointCard from '../endpoint-card'
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
const mockHandleChange = vi.fn()
const mockEnableEndpoint = vi.fn()
const mockDisableEndpoint = vi.fn()
@@ -133,6 +129,10 @@ describe('EndpointCard', () => {
failureFlags.update = false
// Mock Toast.notify to prevent toast elements from accumulating in DOM
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
// Polyfill document.execCommand for copy-to-clipboard in jsdom
if (typeof document.execCommand !== 'function') {
document.execCommand = vi.fn().mockReturnValue(true)
}
})
afterEach(() => {
@@ -192,12 +192,8 @@ describe('EndpointCard', () => {
it('should show delete confirm when delete clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find delete button by its destructive class
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(allButtons[1])
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument()
})
@@ -206,10 +202,7 @@ describe('EndpointCard', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(allButtons[1])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
@@ -218,10 +211,8 @@ describe('EndpointCard', () => {
it('should show edit modal when edit clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
@@ -229,10 +220,8 @@ describe('EndpointCard', () => {
it('should call updateEndpoint when save in modal', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
@@ -243,20 +232,14 @@ describe('EndpointCard', () => {
it('should reset copy state after timeout', async () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find copy button by its class
const allButtons = screen.getAllByRole('button')
const copyButton = allButtons.find(btn => btn.classList.contains('ml-2'))
expect(copyButton).toBeDefined()
if (copyButton) {
fireEvent.click(copyButton)
fireEvent.click(allButtons[2])
act(() => {
vi.advanceTimersByTime(2000)
})
act(() => {
vi.advanceTimersByTime(2000)
})
// After timeout, the component should still be rendered correctly
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
}
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
})
@@ -296,10 +279,7 @@ describe('EndpointCard', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(allButtons[1])
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
@@ -310,10 +290,8 @@ describe('EndpointCard', () => {
it('should hide edit modal when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
@@ -348,9 +326,7 @@ describe('EndpointCard', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(allButtons[1])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalled()
@@ -359,21 +335,15 @@ describe('EndpointCard', () => {
it('should show error toast when update fails', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
expect(editButton).toBeDefined()
if (editButton)
fireEvent.click(editButton)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
// Verify modal is open
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
// Set failure flag before save is clicked
failureFlags.update = true
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
// On error, handleChange is not called
expect(mockHandleChange).not.toHaveBeenCalled()
})
})

View File

@@ -112,8 +112,7 @@ describe('EndpointList', () => {
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
})
@@ -121,9 +120,8 @@ describe('EndpointList', () => {
it('should show modal when add button clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
@@ -131,9 +129,8 @@ describe('EndpointList', () => {
it('should hide modal when cancel clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
@@ -143,9 +140,8 @@ describe('EndpointList', () => {
it('should call createEndpoint when save clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalled()
@@ -158,7 +154,6 @@ describe('EndpointList', () => {
detail.declaration.tool = {} as PluginDetail['declaration']['tool']
render(<EndpointList detail={detail} />)
// Verify the component renders correctly
expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument()
})
})
@@ -177,23 +172,12 @@ describe('EndpointList', () => {
})
})
describe('Tooltip', () => {
it('should render with tooltip content', () => {
render(<EndpointList detail={createPluginDetail()} />)
// Tooltip is rendered - the add button should be visible
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('Create Endpoint Flow', () => {
it('should invalidate endpoint list after successful create', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
@@ -202,9 +186,8 @@ describe('EndpointList', () => {
it('should pass correct params to createEndpoint', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalledWith({

View File

@@ -158,11 +158,8 @@ describe('EndpointModal', () => {
/>,
)
// Find the close button (ActionButton with RiCloseLine icon)
const allButtons = screen.getAllByRole('button')
const closeButton = allButtons.find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
fireEvent.click(allButtons[0])
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
@@ -318,7 +315,16 @@ describe('EndpointModal', () => {
})
describe('Boolean Field Processing', () => {
it('should convert string "true" to boolean true', () => {
it.each([
{ input: 'true', expected: true },
{ input: '1', expected: true },
{ input: 'True', expected: true },
{ input: 'false', expected: false },
{ input: 1, expected: true },
{ input: 0, expected: false },
{ input: true, expected: true },
{ input: false, expected: false },
])('should convert $input to $expected for boolean fields', ({ input, expected }) => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
@@ -326,7 +332,7 @@ describe('EndpointModal', () => {
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'true' }}
defaultValues={{ enabled: input }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
@@ -335,147 +341,7 @@ describe('EndpointModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "1" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: '1' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "True" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'True' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "false" to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'false' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should convert number 1 to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 1 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert number 0 to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 0 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should preserve boolean true value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: true }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should preserve boolean false value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: false }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: expected })
})
it('should not process non-boolean fields', () => {

View File

@@ -136,18 +136,27 @@ describe('SubscriptionList', () => {
expect(screen.getByText('Subscription One')).toBeInTheDocument()
})
it('should highlight the selected subscription when selectedId is provided', () => {
render(
it('should visually distinguish selected subscription from unselected', () => {
const { rerender } = render(
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
selectedId="sub-1"
/>,
)
const selectedButton = screen.getByRole('button', { name: 'Subscription One' })
const selectedRow = selectedButton.closest('div')
const getRowClassName = () =>
screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? ''
expect(selectedRow).toHaveClass('bg-state-base-hover')
const selectedClassName = getRowClassName()
rerender(
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
selectedId="other-id"
/>,
)
expect(selectedClassName).not.toBe(getRowClassName())
})
})
@@ -190,11 +199,9 @@ describe('SubscriptionList', () => {
/>,
)
const deleteButton = container.querySelector('.subscription-delete-btn')
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
expect(deleteButton).toBeTruthy()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(deleteButton)
expect(onSelect).not.toHaveBeenCalled()
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()

View File

@@ -1,17 +1,12 @@
import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import LogViewer from '../log-viewer'
const mockToastNotify = vi.fn()
const mockWriteText = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: { type: string, message: string }) => mockToastNotify(args),
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value }: { value: unknown }) => (
<div data-testid="code-editor">{JSON.stringify(value)}</div>
@@ -62,6 +57,10 @@ beforeEach(() => {
},
configurable: true,
})
vi.spyOn(Toast, 'notify').mockImplementation((args) => {
mockToastNotify(args)
return { clear: vi.fn() }
})
})
describe('LogViewer', () => {
@@ -99,13 +98,20 @@ describe('LogViewer', () => {
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
})
it('should render error styling when response is an error', () => {
render(<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />)
it('should apply distinct styling when response is an error', () => {
const { container: errorContainer } = render(
<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />,
)
const errorWrapperClass = errorContainer.querySelector('[class*="border"]')?.className ?? ''
const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
const wrapper = trigger.parentElement as HTMLElement
cleanup()
expect(wrapper).toHaveClass('border-state-destructive-border')
const { container: okContainer } = render(
<LogViewer logs={[createLog()]} />,
)
const okWrapperClass = okContainer.querySelector('[class*="border"]')?.className ?? ''
expect(errorWrapperClass).not.toBe(okWrapperClass)
})
it('should render raw response text and allow copying', () => {
@@ -121,10 +127,9 @@ describe('LogViewer', () => {
expect(screen.getByText('plain response')).toBeInTheDocument()
const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton)
expect(copyButton).toBeDefined()
if (copyButton)
fireEvent.click(copyButton)
const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton) as HTMLElement
expect(copyButton).toBeTruthy()
fireEvent.click(copyButton)
expect(mockWriteText).toHaveBeenCalledWith('plain response')
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
})

View File

@@ -1,6 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorView } from '../selector-view'
@@ -25,12 +26,6 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
id: 'sub-1',
name: 'Subscription One',
@@ -47,6 +42,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
beforeEach(() => {
vi.clearAllMocks()
mockSubscriptions = [createSubscription()]
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('SubscriptionSelectorView', () => {
@@ -75,18 +71,19 @@ describe('SubscriptionSelectorView', () => {
}).not.toThrow()
})
it('should highlight selected subscription row when selectedId matches', () => {
render(<SubscriptionSelectorView selectedId="sub-1" />)
it('should distinguish selected vs unselected subscription row', () => {
const { rerender } = render(<SubscriptionSelectorView selectedId="sub-1" />)
const selectedRow = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
expect(selectedRow).toHaveClass('bg-state-base-hover')
})
const getRowClassName = () =>
screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? ''
it('should not highlight row when selectedId does not match', () => {
render(<SubscriptionSelectorView selectedId="other-id" />)
const selectedClassName = getRowClassName()
const row = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
expect(row).not.toHaveClass('bg-state-base-hover')
rerender(<SubscriptionSelectorView selectedId="other-id" />)
const unselectedClassName = getRowClassName()
expect(selectedClassName).not.toBe(unselectedClassName)
})
it('should omit header when there are no subscriptions', () => {
@@ -100,11 +97,9 @@ describe('SubscriptionSelectorView', () => {
it('should show delete confirm when delete action is clicked', () => {
const { container } = render(<SubscriptionSelectorView />)
const deleteButton = container.querySelector('.subscription-delete-btn')
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
expect(deleteButton).toBeTruthy()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
})
@@ -113,9 +108,8 @@ describe('SubscriptionSelectorView', () => {
const onSelect = vi.fn()
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
const deleteButton = container.querySelector('.subscription-delete-btn')
if (deleteButton)
fireEvent.click(deleteButton)
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
@@ -127,9 +121,8 @@ describe('SubscriptionSelectorView', () => {
const onSelect = vi.fn()
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
const deleteButton = container.querySelector('.subscription-delete-btn')
if (deleteButton)
fireEvent.click(deleteButton)
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ }))

View File

@@ -1,6 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import SubscriptionCard from '../subscription-card'
@@ -29,12 +30,6 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
id: 'sub-1',
name: 'Subscription One',
@@ -50,6 +45,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('SubscriptionCard', () => {
@@ -69,11 +65,9 @@ describe('SubscriptionCard', () => {
it('should open delete confirmation when delete action is clicked', () => {
const { container } = render(<SubscriptionCard data={createSubscription()} />)
const deleteButton = container.querySelector('.subscription-delete-btn')
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
expect(deleteButton).toBeTruthy()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
})
@@ -81,9 +75,7 @@ describe('SubscriptionCard', () => {
it('should open edit modal when edit action is clicked', () => {
const { container } = render(<SubscriptionCard data={createSubscription()} />)
const actionButtons = container.querySelectorAll('button')
const editButton = actionButtons[0]
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()

View File

@@ -1,4 +1,3 @@
import type { PropsWithChildren } from 'react'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
@@ -16,23 +15,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('next/image', () => ({
default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => (
// eslint-disable-next-line next/no-img-element
<img src={src} alt={alt} width={width} height={height} data-testid="mock-image" />
),
}))
vi.mock('next/dynamic', () => ({
default: (importFn: () => Promise<{ default: React.ComponentType<unknown> }>, options?: { ssr?: boolean }) => {
const DynamicComponent = ({ children, ...props }: PropsWithChildren) => {
return <div data-testid="dynamic-component" data-ssr={options?.ssr ?? true} {...props}>{children}</div>
}
DynamicComponent.displayName = 'DynamicComponent'
return DynamicComponent
},
}))
let mockShowImportDSLModal = false
const mockSetShowImportDSLModal = vi.fn((value: boolean) => {
mockShowImportDSLModal = value
@@ -247,18 +229,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
useToastContext: () => ({
notify: vi.fn(),
}),
ToastContext: {
Provider: ({ children }: PropsWithChildren) => children,
},
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
@@ -276,7 +246,7 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({ children }: PropsWithChildren) => (
WorkflowWithInnerContext: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-inner-context">{children}</div>
),
}))
@@ -300,16 +270,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
}),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => (
<div data-testid="dsl-export-confirm-modal">
<span data-testid="env-count">{envList.length}</span>
<button data-testid="export-confirm" onClick={onConfirm}>Confirm</button>
<button data-testid="export-close" onClick={onClose}>Close</button>
</div>
),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
@@ -322,125 +282,6 @@ vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyNameBySystem: (key: string) => key,
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: {
title: string
content: string
isShow: boolean
onConfirm: () => void
onCancel: () => void
isLoading?: boolean
isDisabled?: boolean
}) => isShow
? (
<div data-testid="confirm-modal">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button
data-testid="confirm-btn"
onClick={onConfirm}
disabled={isDisabled || isLoading}
>
Confirm
</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow, onClose, className }: PropsWithChildren<{
isShow: boolean
onClose: () => void
className?: string
}>) => isShow
? (
<div data-testid="modal" className={className} onClick={e => e.target === e.currentTarget && onClose()}>
{children}
</div>
)
: null,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange, placeholder }: {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
}) => (
<input
data-testid="input"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
),
}))
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, className }: {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
placeholder?: string
className?: string
}) => (
<textarea
data-testid="textarea"
value={value}
onChange={onChange}
placeholder={placeholder}
className={className}
/>
),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, iconType, icon, background, imageUrl, className, size }: {
onClick?: () => void
iconType?: string
icon?: string
background?: string
imageUrl?: string
className?: string
size?: string
}) => (
<div
data-testid="app-icon"
data-icon-type={iconType}
data-icon={icon}
data-background={background}
data-image-url={imageUrl}
data-size={size}
className={className}
onClick={onClick}
/>
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void
onClose: () => void
}) => (
<div data-testid="app-icon-picker">
<button
data-testid="select-emoji"
onClick={() => onSelect({ type: 'emoji', icon: '🚀', background: '#000000' })}
>
Select Emoji
</button>
<button
data-testid="select-image"
onClick={() => onSelect({ type: 'image', url: 'https://example.com/icon.png' })}
>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>Close</button>
</div>
),
}))
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ file, updateFile, className, accept, displayName }: {
file?: File
@@ -466,12 +307,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
),
}))
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({
notify: vi.fn(),
})),
}))
vi.mock('../rag-pipeline-header', () => ({
default: () => <div data-testid="rag-pipeline-header" />,
}))
@@ -512,6 +347,28 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
// Silence expected console.error from Dialog/Modal rendering
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
// Helper to find the name input in PublishAsKnowledgePipelineModal
function getNameInput() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
}
// Helper to find the description textarea in PublishAsKnowledgePipelineModal
function getDescriptionTextarea() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.descriptionPlaceholder')
}
// Helper to find the AppIcon span in PublishAsKnowledgePipelineModal
// HeadlessUI Dialog renders via portal to document.body, so we search the full document
function getAppIcon() {
const emoji = document.querySelector('em-emoji')
return emoji?.closest('span') as HTMLElement
}
describe('Conversion', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -546,7 +403,8 @@ describe('Conversion', () => {
it('should render PipelineScreenShot component', () => {
render(<Conversion />)
expect(screen.getByTestId('mock-image')).toBeInTheDocument()
// PipelineScreenShot renders a <picture> element with <source> children
expect(document.querySelector('picture')).toBeInTheDocument()
})
})
@@ -557,8 +415,9 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByTestId('confirm-title')).toHaveTextContent('datasetPipeline.conversion.confirm.title')
// Real Confirm renders title and content via portal
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.content')).toBeInTheDocument()
})
it('should hide confirm modal when cancel is clicked', () => {
@@ -566,10 +425,11 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
// Real Confirm renders cancel button with i18n text
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
})
})
@@ -588,7 +448,7 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalledWith('test-dataset-id', expect.objectContaining({
@@ -607,12 +467,12 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
})
})
@@ -625,12 +485,13 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalled()
})
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
// Confirm modal stays open on failure
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
})
it('should show error toast when conversion throws error', async () => {
@@ -642,7 +503,7 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalled()
@@ -681,23 +542,24 @@ describe('PipelineScreenShot', () => {
it('should render without crashing', () => {
render(<PipelineScreenShot />)
expect(screen.getByTestId('mock-image')).toBeInTheDocument()
expect(document.querySelector('picture')).toBeInTheDocument()
})
it('should render with correct image attributes', () => {
it('should render source elements for different resolutions', () => {
render(<PipelineScreenShot />)
const img = screen.getByTestId('mock-image')
expect(img).toHaveAttribute('alt', 'Pipeline Screenshot')
expect(img).toHaveAttribute('width', '692')
expect(img).toHaveAttribute('height', '456')
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(3)
expect(sources[0]).toHaveAttribute('media', '(resolution: 1x)')
expect(sources[1]).toHaveAttribute('media', '(resolution: 2x)')
expect(sources[2]).toHaveAttribute('media', '(resolution: 3x)')
})
it('should use correct theme-based source path', () => {
render(<PipelineScreenShot />)
const img = screen.getByTestId('mock-image')
expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png')
const source = document.querySelector('source')
expect(source).toHaveAttribute('srcSet', '/public/screenshots/light/Pipeline.png')
})
})
@@ -752,20 +614,22 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should render name input with default value from store', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const input = screen.getByTestId('input')
const input = getNameInput()
expect(input).toHaveValue('Test Knowledge')
})
it('should render description textarea', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('textarea')).toBeInTheDocument()
expect(getDescriptionTextarea()).toBeInTheDocument()
})
it('should render app icon', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
// Real AppIcon renders an em-emoji custom element inside a span
// HeadlessUI Dialog renders via portal, so search the full document
expect(document.querySelector('em-emoji')).toBeInTheDocument()
})
it('should render cancel and confirm buttons', () => {
@@ -780,7 +644,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update name when input changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: 'New Pipeline Name' } })
expect(input).toHaveValue('New Pipeline Name')
@@ -789,7 +653,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update description when textarea changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByTestId('textarea')
const textarea = getDescriptionTextarea()
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(textarea).toHaveValue('New description')
@@ -816,8 +680,8 @@ describe('PublishAsKnowledgePipelineModal', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } })
fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } })
fireEvent.change(getNameInput(), { target: { value: ' Trimmed Name ' } })
fireEvent.change(getDescriptionTextarea(), { target: { value: ' Trimmed Description ' } })
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
@@ -831,40 +695,57 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should show app icon picker when icon is clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
const appIcon = getAppIcon()
fireEvent.click(appIcon)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
// Real AppIconPicker renders with Cancel and OK buttons
expect(screen.getByRole('button', { name: /iconPicker\.cancel/ })).toBeInTheDocument()
})
it('should update icon when emoji is selected', () => {
it('should update icon when emoji is selected', async () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
const appIcon = getAppIcon()
fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('select-emoji'))
// Click the first emoji in the grid (search full document since Dialog uses portal)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
expect(gridEmojis.length).toBeGreaterThan(0)
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
// Picker should close
await waitFor(() => {
expect(screen.queryByRole('button', { name: /iconPicker\.cancel/ })).not.toBeInTheDocument()
})
})
it('should update icon when image is selected', () => {
it('should switch to image tab in icon picker', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
const appIcon = getAppIcon()
fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('select-image'))
// Switch to image tab
const imageTab = screen.getByRole('button', { name: /iconPicker\.image/ })
fireEvent.click(imageTab)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Picker should still be open
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
})
it('should close picker and restore icon when picker is closed', () => {
it('should close picker when cancel is clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
const appIcon = getAppIcon()
fireEvent.click(appIcon)
expect(screen.getByRole('button', { name: /iconPicker\.cancel/ })).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-picker'))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /iconPicker\.ok/ })).not.toBeInTheDocument()
})
})
@@ -872,7 +753,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should disable publish button when name is empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('input'), { target: { value: '' } })
fireEvent.change(getNameInput(), { target: { value: '' } })
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
expect(publishButton).toBeDisabled()
@@ -881,7 +762,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should disable publish button when name is only whitespace', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } })
fireEvent.change(getNameInput(), { target: { value: ' ' } })
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
expect(publishButton).toBeDisabled()
@@ -908,7 +789,8 @@ describe('PublishAsKnowledgePipelineModal', () => {
const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
// HeadlessUI Dialog renders via portal, so search the full document
expect(document.querySelector('em-emoji')).toBeInTheDocument()
})
})
})
@@ -1132,12 +1014,18 @@ describe('Integration Tests', () => {
/>,
)
fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } })
fireEvent.change(getNameInput(), { target: { value: 'My Pipeline' } })
fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } })
fireEvent.change(getDescriptionTextarea(), { target: { value: 'A great pipeline' } })
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-emoji'))
// Open picker and select an emoji
const appIcon = getAppIcon()
fireEvent.click(appIcon)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
if (gridEmojis.length > 0) {
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
}
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
@@ -1145,9 +1033,7 @@ describe('Integration Tests', () => {
expect(mockOnConfirm).toHaveBeenCalledWith(
'My Pipeline',
expect.objectContaining({
icon_type: 'emoji',
icon: '🚀',
icon_background: '#000000',
icon_type: expect.any(String),
}),
'A great pipeline',
)
@@ -1170,7 +1056,7 @@ describe('Edge Cases', () => {
/>,
)
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: '' } })
expect(input).toHaveValue('')
})
@@ -1186,7 +1072,7 @@ describe('Edge Cases', () => {
)
const longName = 'A'.repeat(1000)
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: longName } })
expect(input).toHaveValue(longName)
})
@@ -1200,7 +1086,7 @@ describe('Edge Cases', () => {
)
const specialName = '<script>alert("xss")</script>'
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: specialName } })
expect(input).toHaveValue(specialName)
})
@@ -1226,8 +1112,8 @@ describe('Accessibility', () => {
/>,
)
expect(screen.getByTestId('input')).toBeInTheDocument()
expect(screen.getByTestId('textarea')).toBeInTheDocument()
expect(getNameInput()).toBeInTheDocument()
expect(getDescriptionTextarea()).toBeInTheDocument()
})
it('should have accessible buttons', () => {

View File

@@ -20,6 +20,11 @@ describe('VersionMismatchModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('rendering', () => {

View File

@@ -2,6 +2,7 @@ import type { FormData, InputFieldFormProps } from '../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { PipelineInputVarType } from '@/models/pipeline'
import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks'
import InputFieldForm from '../index'
@@ -25,12 +26,6 @@ vi.mock('@/service/use-common', () => ({
}),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const createFormData = (overrides?: Partial<FormData>): FormData => ({
type: PipelineInputVarType.textInput,
label: 'Test Label',
@@ -85,6 +80,12 @@ const renderHookWithProviders = <TResult,>(hook: () => TResult) => {
return renderHook(hook, { wrapper: TestWrapper })
}
// Silence expected console.error from form submit preventDefault
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('InputFieldForm', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -197,7 +198,6 @@ describe('InputFieldForm', () => {
})
it('should show Toast error when form validation fails on submit', async () => {
const Toast = await import('@/app/components/base/toast')
const initialData = createFormData({
variable: '', // Empty variable should fail validation
label: 'Test Label',
@@ -210,7 +210,7 @@ describe('InputFieldForm', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.default.notify).toHaveBeenCalledWith(
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.any(String),

View File

@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import Publisher from '../index'
import Popup from '../popup'
@@ -18,53 +19,6 @@ vi.mock('next/link', () => ({
),
}))
let keyPressCallback: ((e: KeyboardEvent) => void) | null = null
vi.mock('ahooks', () => ({
useBoolean: (defaultValue = false) => {
const [value, setValue] = React.useState(defaultValue)
return [value, {
setTrue: () => setValue(true),
setFalse: () => setValue(false),
toggle: () => setValue(v => !v),
}]
},
useKeyPress: (key: string, callback: (e: KeyboardEvent) => void) => {
keyPressCallback = callback
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => {
mockPortalOpen = open
return <div data-testid="portal-elem" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: {
children: React.ReactNode
onClick: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
},
}))
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
vi.mock('@/app/components/workflow/hooks', () => ({
@@ -120,11 +74,6 @@ vi.mock('@/context/provider-context', () => ({
}))
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => 'https://api.dify.ai/v1/datasets/test-dataset-id',
@@ -207,7 +156,9 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{ui}
</ToastContext.Provider>
</QueryClientProvider>,
)
}
@@ -215,8 +166,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
describe('publisher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
keyPressCallback = null
vi.spyOn(console, 'error').mockImplementation(() => {})
mockPublishedAt.mockReturnValue(null)
mockDraftUpdatedAt.mockReturnValue(1700000000)
mockPipelineId.mockReturnValue('test-pipeline-id')
@@ -236,8 +186,9 @@ describe('publisher', () => {
it('should render portal element in closed state by default', () => {
renderWithQueryClient(<Publisher />)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
const trigger = screen.getByText('workflow.common.publish').closest('[data-state]')
expect(trigger).toHaveAttribute('data-state', 'closed')
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
it('should render down arrow icon in button', () => {
@@ -252,24 +203,24 @@ describe('publisher', () => {
it('should open popup when trigger is clicked', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
})
it('should close popup when trigger is clicked again while open', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger')) // open
fireEvent.click(screen.getByText('workflow.common.publish')) // open
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('portal-trigger')) // close
fireEvent.click(screen.getByText('workflow.common.publish')) // close
await waitFor(() => {
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
})
})
@@ -278,20 +229,20 @@ describe('publisher', () => {
it('should call handleSyncWorkflowDraft when popup opens', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should not call handleSyncWorkflowDraft when popup closes', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger')) // open
fireEvent.click(screen.getByText('workflow.common.publish')) // open
vi.clearAllMocks()
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('portal-trigger')) // close
fireEvent.click(screen.getByText('workflow.common.publish')) // close
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
@@ -306,10 +257,10 @@ describe('publisher', () => {
it('should render popup content when opened', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
})
})
@@ -811,10 +762,8 @@ describe('publisher', () => {
mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 })
renderWithQueryClient(<Popup />)
const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
expect(mockEvent.preventDefault).toHaveBeenCalled()
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
@@ -834,10 +783,8 @@ describe('publisher', () => {
vi.clearAllMocks()
const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
@@ -845,8 +792,7 @@ describe('publisher', () => {
mockPublishedAt.mockReturnValue(null)
renderWithQueryClient(<Popup />)
const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
await waitFor(() => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
@@ -861,16 +807,14 @@ describe('publisher', () => {
}))
renderWithQueryClient(<Popup />)
const mockEvent1 = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent1)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
await waitFor(() => {
const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i })
expect(publishButton).toBeDisabled()
})
const mockEvent2 = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent2)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
expect(mockPublishWorkflow).toHaveBeenCalledTimes(1)
@@ -1066,10 +1010,10 @@ describe('publisher', () => {
it('should show Publisher button and open popup with Popup component', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)

View File

@@ -0,0 +1,99 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
// Mock return value for useInspectVarsCrudCommon
const mockApis = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis)
vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args),
}))
const mockConfigsMap = {
flowId: 'pipeline-123',
flowType: 'rag_pipeline',
fileSettings: {
image: { enabled: false },
fileUploadConfig: {},
},
}
vi.mock('../use-configs-map', () => ({
useConfigsMap: () => mockConfigsMap,
}))
describe('useInspectVarsCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify the hook composes useConfigsMap with useInspectVarsCrudCommon
describe('Composition', () => {
it('should pass configsMap to useInspectVarsCrudCommon', () => {
renderHook(() => useInspectVarsCrud())
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith(
expect.objectContaining({
flowId: 'pipeline-123',
flowType: 'rag_pipeline',
}),
)
})
it('should return all APIs from useInspectVarsCrudCommon', () => {
const { result } = renderHook(() => useInspectVarsCrud())
expect(result.current.hasNodeInspectVars).toBe(mockApis.hasNodeInspectVars)
expect(result.current.fetchInspectVarValue).toBe(mockApis.fetchInspectVarValue)
expect(result.current.editInspectVarValue).toBe(mockApis.editInspectVarValue)
expect(result.current.deleteInspectVar).toBe(mockApis.deleteInspectVar)
expect(result.current.deleteAllInspectorVars).toBe(mockApis.deleteAllInspectorVars)
expect(result.current.resetToLastRunVar).toBe(mockApis.resetToLastRunVar)
expect(result.current.resetConversationVar).toBe(mockApis.resetConversationVar)
})
})
// Verify the hook spreads all returned properties
describe('API Surface', () => {
it('should expose all expected API methods', () => {
const { result } = renderHook(() => useInspectVarsCrud())
const expectedKeys = [
'hasNodeInspectVars',
'hasSetInspectVar',
'fetchInspectVarValue',
'editInspectVarValue',
'renameInspectVarName',
'appendNodeInspectVars',
'deleteInspectVar',
'deleteNodeInspectorVars',
'deleteAllInspectorVars',
'isInspectVarEdited',
'resetToLastRunVar',
'invalidateSysVarValues',
'resetConversationVar',
'invalidateConversationVarValues',
]
for (const key of expectedKeys)
expect(result.current).toHaveProperty(key)
})
})
})

View File

@@ -46,6 +46,7 @@ describe('usePipelineInit', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockWorkflowStoreGetState.mockReturnValue({
setEnvSecrets: mockSetEnvSecrets,
@@ -82,7 +83,7 @@ describe('usePipelineInit', () => {
})
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('hook initialization', () => {

View File

@@ -1,26 +1,17 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from './countdown'
// Mock useCountDown from ahooks
let mockTime = COUNT_DOWN_TIME_MS
let mockOnEnd: (() => void) | undefined
vi.mock('ahooks', () => ({
useCountDown: ({ onEnd }: { leftTime: number, onEnd?: () => void }) => {
mockOnEnd = onEnd
return [mockTime]
},
}))
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../countdown'
describe('Countdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTime = COUNT_DOWN_TIME_MS
mockOnEnd = undefined
vi.useFakeTimers()
localStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
@@ -29,16 +20,15 @@ describe('Countdown', () => {
})
it('should display countdown time when time > 0', () => {
mockTime = 30000 // 30 seconds
localStorage.setItem(COUNT_DOWN_KEY, '30000')
render(<Countdown />)
// The countdown displays number and 's' in the same span
expect(screen.getByText(/30/)).toBeInTheDocument()
expect(screen.getByText(/s$/)).toBeInTheDocument()
})
it('should display resend link when time <= 0', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
@@ -46,7 +36,7 @@ describe('Countdown', () => {
})
it('should not display resend link when time > 0', () => {
mockTime = 1000
localStorage.setItem(COUNT_DOWN_KEY, '1000')
render(<Countdown />)
expect(screen.queryByText('login.checkCode.resend')).not.toBeInTheDocument()
@@ -57,7 +47,7 @@ describe('Countdown', () => {
describe('State Management', () => {
it('should initialize leftTime from localStorage if available', () => {
const savedTime = 45000
vi.mocked(localStorage.getItem).mockReturnValueOnce(String(savedTime))
localStorage.setItem(COUNT_DOWN_KEY, String(savedTime))
render(<Countdown />)
@@ -65,25 +55,26 @@ describe('Countdown', () => {
})
it('should use default COUNT_DOWN_TIME_MS when localStorage is empty', () => {
vi.mocked(localStorage.getItem).mockReturnValueOnce(null)
render(<Countdown />)
expect(localStorage.getItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
})
it('should save time to localStorage on time change', () => {
mockTime = 50000
render(<Countdown />)
expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, String(mockTime))
act(() => {
vi.advanceTimersByTime(1000)
})
expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, expect.any(String))
})
})
// Event Handler Tests
describe('Event Handlers', () => {
it('should call onResend callback when resend is clicked', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
const onResend = vi.fn()
render(<Countdown onResend={onResend} />)
@@ -95,7 +86,7 @@ describe('Countdown', () => {
})
it('should reset countdown when resend is clicked', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
@@ -106,7 +97,7 @@ describe('Countdown', () => {
})
it('should work without onResend callback (optional prop)', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
@@ -118,11 +109,12 @@ describe('Countdown', () => {
// Countdown End Tests
describe('Countdown End', () => {
it('should remove localStorage item when countdown ends', () => {
localStorage.setItem(COUNT_DOWN_KEY, '1000')
render(<Countdown />)
// Simulate countdown end
act(() => {
mockOnEnd?.()
vi.advanceTimersByTime(2000)
})
expect(localStorage.removeItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
@@ -132,28 +124,28 @@ describe('Countdown', () => {
// Edge Cases
describe('Edge Cases', () => {
it('should handle time exactly at 0', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should handle negative time values', () => {
mockTime = -1000
localStorage.setItem(COUNT_DOWN_KEY, '-1000')
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should round time display correctly', () => {
mockTime = 29500 // Should display as 30 (Math.round)
localStorage.setItem(COUNT_DOWN_KEY, '29500')
render(<Countdown />)
expect(screen.getByText(/30/)).toBeInTheDocument()
})
it('should display 1 second correctly', () => {
mockTime = 1000
localStorage.setItem(COUNT_DOWN_KEY, '1000')
render(<Countdown />)
expect(screen.getByText(/^1/)).toBeInTheDocument()
@@ -163,8 +155,8 @@ describe('Countdown', () => {
// Props Tests
describe('Props', () => {
it('should render correctly with onResend prop', () => {
localStorage.setItem(COUNT_DOWN_KEY, '0')
const onResend = vi.fn()
mockTime = 0
render(<Countdown onResend={onResend} />)

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