mirror of
https://github.com/langgenius/dify.git
synced 2026-02-13 00:50:12 -05:00
Compare commits
9 Commits
refactor/r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e03d6284 | ||
|
|
84d090db33 | ||
|
|
f3f56f03e3 | ||
|
|
b6d506828b | ||
|
|
16df9851a2 | ||
|
|
c0ffb6db2a | ||
|
|
0118b45cff | ||
|
|
8fd3eeb760 | ||
|
|
f233e2036f |
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
241
api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py
Normal file
241
api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py
Normal 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()
|
||||
@@ -21,6 +21,7 @@ def oceanbase_vector():
|
||||
database="test",
|
||||
password="difyai123456",
|
||||
enable_hybrid_search=True,
|
||||
batch_size=10,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
70
api/uv.lock
generated
@@ -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]]
|
||||
|
||||
459
web/__tests__/apps/app-card-operations-flow.test.tsx
Normal file
459
web/__tests__/apps/app-card-operations-flow.test.tsx
Normal 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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
439
web/__tests__/apps/app-list-browsing-flow.test.tsx
Normal file
439
web/__tests__/apps/app-list-browsing-flow.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
465
web/__tests__/apps/create-app-flow.test.tsx
Normal file
465
web/__tests__/apps/create-app-flow.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
59
web/app/components/base/content-dialog/index.spec.tsx
Normal file
59
web/app/components/base/content-dialog/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
138
web/app/components/base/dialog/index.spec.tsx
Normal file
138
web/app/components/base/dialog/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
214
web/app/components/base/fullscreen-modal/index.spec.tsx
Normal file
214
web/app/components/base/fullscreen-modal/index.spec.tsx
Normal 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!)
|
||||
})
|
||||
})
|
||||
})
|
||||
205
web/app/components/base/new-audio-button/index.spec.tsx
Normal file
205
web/app/components/base/new-audio-button/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
49
web/app/components/base/notion-connector/index.spec.tsx
Normal file
49
web/app/components/base/notion-connector/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
83
web/app/components/base/skeleton/index.spec.tsx
Normal file
83
web/app/components/base/skeleton/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
77
web/app/components/base/slider/index.spec.tsx
Normal file
77
web/app/components/base/slider/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
141
web/app/components/base/sort/index.spec.tsx
Normal file
141
web/app/components/base/sort/index.spec.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
84
web/app/components/base/switch/index.spec.tsx
Normal file
84
web/app/components/base/switch/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
104
web/app/components/base/tag/index.spec.tsx
Normal file
104
web/app/components/base/tag/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>,
|
||||
}))
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
31
web/app/components/datasets/create/__tests__/icons.spec.ts
Normal file
31
web/app/components/datasets/create/__tests__/icons.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
246
web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
Normal file
246
web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
Normal 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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
220
web/app/components/plugins/marketplace/__tests__/query.spec.tsx
Normal file
220
web/app/components/plugins/marketplace/__tests__/query.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
267
web/app/components/plugins/marketplace/__tests__/state.spec.tsx
Normal file
267
web/app/components/plugins/marketplace/__tests__/state.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' }))
|
||||
})
|
||||
|
||||
@@ -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/ }))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -20,6 +20,11 @@ describe('VersionMismatchModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user