diff --git a/api/.env.example b/api/.env.example index b36d3b2e8e..17efcc9f89 100644 --- a/api/.env.example +++ b/api/.env.example @@ -556,6 +556,8 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false WORKFLOW_LOG_RETENTION_DAYS=30 # Batch size for workflow log cleanup operations (default: 100) WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 +# Comma-separated list of workflow IDs to clean logs for +WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= # App configuration APP_MAX_EXECUTION_TIME=1200 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index caa2418a92..f5b8fe84ca 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -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 CliApiConfig(BaseSettings): """ @@ -1362,6 +1367,9 @@ class WorkflowLogConfig(BaseSettings): WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field( default=100, description="Batch size for workflow run log cleanup operations" ) + WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: str = Field( + default="", description="Comma-separated list of workflow IDs to clean logs for" + ) class SwaggerUIConfig(BaseSettings): diff --git a/api/configs/middleware/vdb/oceanbase_config.py b/api/configs/middleware/vdb/oceanbase_config.py index 7c9376f86b..27ec99e56a 100644 --- a/api/configs/middleware/vdb/oceanbase_config.py +++ b/api/configs/middleware/vdb/oceanbase_config.py @@ -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, + ) diff --git a/api/core/plugin/impl/tool.py b/api/core/plugin/impl/tool.py index 6fa5136b42..cc38ecfce2 100644 --- a/api/core/plugin/impl/tool.py +++ b/api/core/plugin/impl/tool.py @@ -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] diff --git a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py index dc3b70140b..86c1e65f47 100644 --- a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py +++ b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py @@ -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, ), ) diff --git a/api/pyproject.toml b/api/pyproject.toml index 5aac2ae69f..9fc348fcf6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -71,7 +71,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", diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 17e01a6e18..ffa87b209f 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -264,9 +264,15 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): batch_size: int, run_types: Sequence[WorkflowType] | None = None, tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, ) -> Sequence[WorkflowRun]: """ Fetch ended workflow runs in a time window for archival and clean batching. + + Optional filters: + - run_types + - tenant_ids + - workflow_ids """ ... diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 00cb979e17..7935dfb225 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -386,6 +386,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): batch_size: int, run_types: Sequence[WorkflowType] | None = None, tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, ) -> Sequence[WorkflowRun]: """ Fetch ended workflow runs in a time window for archival and clean batching. @@ -394,7 +395,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): - created_at in [start_from, end_before) - type in run_types (when provided) - status is an ended state - - optional tenant_id filter and cursor (last_seen) for pagination + - optional tenant_id, workflow_id filters and cursor (last_seen) for pagination """ with self._session_maker() as session: stmt = ( @@ -417,6 +418,9 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): if tenant_ids: stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids)) + if workflow_ids: + stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids)) + if last_seen: stmt = stmt.where( or_( diff --git a/api/schedule/clean_workflow_runlogs_precise.py b/api/schedule/clean_workflow_runlogs_precise.py index db4198720d..ebb8d52924 100644 --- a/api/schedule/clean_workflow_runlogs_precise.py +++ b/api/schedule/clean_workflow_runlogs_precise.py @@ -4,7 +4,6 @@ import time from collections.abc import Sequence import click -from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker import app @@ -13,6 +12,7 @@ from extensions.ext_database import db from models.model import ( AppAnnotationHitHistory, Conversation, + DatasetRetrieverResource, Message, MessageAgentThought, MessageAnnotation, @@ -20,7 +20,10 @@ from models.model import ( MessageFeedback, MessageFile, ) -from models.workflow import ConversationVariable, WorkflowAppLog, WorkflowNodeExecutionModel, WorkflowRun +from models.web import SavedMessage +from models.workflow import ConversationVariable, WorkflowRun +from repositories.factory import DifyAPIRepositoryFactory +from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository logger = logging.getLogger(__name__) @@ -29,8 +32,15 @@ MAX_RETRIES = 3 BATCH_SIZE = dify_config.WORKFLOW_LOG_CLEANUP_BATCH_SIZE -@app.celery.task(queue="dataset") -def clean_workflow_runlogs_precise(): +def _get_specific_workflow_ids() -> list[str]: + workflow_ids_str = dify_config.WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS.strip() + if not workflow_ids_str: + return [] + return [wid.strip() for wid in workflow_ids_str.split(",") if wid.strip()] + + +@app.celery.task(queue="retention") +def clean_workflow_runlogs_precise() -> None: """Clean expired workflow run logs with retry mechanism and complete message cascade""" click.echo(click.style("Start clean workflow run logs (precise mode with complete cascade).", fg="green")) @@ -39,48 +49,48 @@ def clean_workflow_runlogs_precise(): retention_days = dify_config.WORKFLOW_LOG_RETENTION_DAYS cutoff_date = datetime.datetime.now() - datetime.timedelta(days=retention_days) session_factory = sessionmaker(db.engine, expire_on_commit=False) + workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + workflow_ids = _get_specific_workflow_ids() + workflow_ids_filter = workflow_ids or None try: - with session_factory.begin() as session: - total_workflow_runs = session.query(WorkflowRun).where(WorkflowRun.created_at < cutoff_date).count() - if total_workflow_runs == 0: - logger.info("No expired workflow run logs found") - return - logger.info("Found %s expired workflow run logs to clean", total_workflow_runs) - total_deleted = 0 failed_batches = 0 batch_count = 0 + last_seen: tuple[datetime.datetime, str] | None = None while True: + run_rows = workflow_run_repo.get_runs_batch_by_time_range( + start_from=None, + end_before=cutoff_date, + last_seen=last_seen, + batch_size=BATCH_SIZE, + workflow_ids=workflow_ids_filter, + ) + + if not run_rows: + if batch_count == 0: + logger.info("No expired workflow run logs found") + break + + last_seen = (run_rows[-1].created_at, run_rows[-1].id) + batch_count += 1 with session_factory.begin() as session: - workflow_run_ids = session.scalars( - select(WorkflowRun.id) - .where(WorkflowRun.created_at < cutoff_date) - .order_by(WorkflowRun.created_at, WorkflowRun.id) - .limit(BATCH_SIZE) - ).all() + success = _delete_batch(session, workflow_run_repo, run_rows, failed_batches) - if not workflow_run_ids: + if success: + total_deleted += len(run_rows) + failed_batches = 0 + else: + failed_batches += 1 + if failed_batches >= MAX_RETRIES: + logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES) break - - batch_count += 1 - - success = _delete_batch(session, workflow_run_ids, failed_batches) - - if success: - total_deleted += len(workflow_run_ids) - failed_batches = 0 else: - failed_batches += 1 - if failed_batches >= MAX_RETRIES: - logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES) - break - else: - # Calculate incremental delay times: 5, 10, 15 minutes - retry_delay_minutes = failed_batches * 5 - logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes) - time.sleep(retry_delay_minutes * 60) - continue + # Calculate incremental delay times: 5, 10, 15 minutes + retry_delay_minutes = failed_batches * 5 + logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes) + time.sleep(retry_delay_minutes * 60) + continue logger.info("Cleanup completed: %s expired workflow run logs deleted", total_deleted) @@ -93,10 +103,16 @@ def clean_workflow_runlogs_precise(): click.echo(click.style(f"Cleaned workflow run logs from db success latency: {execution_time:.2f}s", fg="green")) -def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_count: int) -> bool: +def _delete_batch( + session: Session, + workflow_run_repo, + workflow_runs: Sequence[WorkflowRun], + attempt_count: int, +) -> bool: """Delete a single batch of workflow runs and all related data within a nested transaction.""" try: with session.begin_nested(): + workflow_run_ids = [run.id for run in workflow_runs] message_data = ( session.query(Message.id, Message.conversation_id) .where(Message.workflow_run_id.in_(workflow_run_ids)) @@ -107,11 +123,13 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou if message_id_list: message_related_models = [ AppAnnotationHitHistory, + DatasetRetrieverResource, MessageAgentThought, MessageChain, MessageFile, MessageAnnotation, MessageFeedback, + SavedMessage, ] for model in message_related_models: session.query(model).where(model.message_id.in_(message_id_list)).delete(synchronize_session=False) # type: ignore @@ -122,14 +140,6 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou synchronize_session=False ) - session.query(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(workflow_run_ids)).delete( - synchronize_session=False - ) - - session.query(WorkflowNodeExecutionModel).where( - WorkflowNodeExecutionModel.workflow_run_id.in_(workflow_run_ids) - ).delete(synchronize_session=False) - if conversation_id_list: session.query(ConversationVariable).where( ConversationVariable.conversation_id.in_(conversation_id_list) @@ -139,7 +149,22 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou synchronize_session=False ) - session.query(WorkflowRun).where(WorkflowRun.id.in_(workflow_run_ids)).delete(synchronize_session=False) + def _delete_node_executions(active_session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: + run_ids = [run.id for run in runs] + repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( + session_maker=sessionmaker(bind=active_session.get_bind(), expire_on_commit=False) + ) + return repo.delete_by_runs(active_session, run_ids) + + def _delete_trigger_logs(active_session: Session, run_ids: Sequence[str]) -> int: + trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(active_session) + return trigger_repo.delete_by_run_ids(run_ids) + + workflow_run_repo.delete_runs_with_related( + workflow_runs, + delete_node_executions=_delete_node_executions, + delete_trigger_logs=_delete_trigger_logs, + ) return True diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 3329ac349c..859fc1902b 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -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) diff --git a/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py b/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py new file mode 100644 index 0000000000..8b57be08c5 --- /dev/null +++ b/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py @@ -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() diff --git a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py b/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py index 8fbbbe61b8..2db6732354 100644 --- a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py +++ b/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py @@ -21,6 +21,7 @@ def oceanbase_vector(): database="test", password="difyai123456", enable_hybrid_search=True, + batch_size=10, ), ) diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_service.py b/api/tests/test_containers_integration_tests/services/test_metadata_service.py index c8ced3f3a5..e04725627b 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_service.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_service.py @@ -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 diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index 8c80e2b4ad..50826d6798 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -62,6 +62,9 @@ class FakeRepo: end_before: datetime.datetime, last_seen: tuple[datetime.datetime, str] | None, batch_size: int, + run_types=None, + tenant_ids=None, + workflow_ids=None, ) -> list[FakeRun]: if self.call_idx >= len(self.batches): return [] diff --git a/api/tests/unit_tests/services/test_metadata_partial_update.py b/api/tests/unit_tests/services/test_metadata_partial_update.py index 00162c10e4..60252784bc 100644 --- a/api/tests/unit_tests/services/test_metadata_partial_update.py +++ b/api/tests/unit_tests/services/test_metadata_partial_update.py @@ -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() diff --git a/api/uv.lock b/api/uv.lock index a1a6cfaffd..67eca6f32e 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", @@ -136,16 +136,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.2" +version = "1.18.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/93/07f5ba5d8e4f4049e864faa9d822bbbbfb6f3223a4ffb1376768ab9ee4b8/alembic-1.18.2.tar.gz", hash = "sha256:1c3ddb635f26efbc80b1b90c5652548202022d4e760f6a78d6d85959280e3684", size = 2048272, upload-time = "2026-01-28T21:23:30.914Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/60/ced4277ccf61f91eb03c4ac9f63b9567eb814f9ab1cd7835f00fbd5d0c14/alembic-1.18.2-py3-none-any.whl", hash = "sha256:18a5f6448af4864cc308aadf33eb37c0116da9a60fd9bb3f31ccb1b522b4a9b9", size = 261953, upload-time = "2026-01-28T21:23:32.508Z" }, + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] [[package]] @@ -428,11 +428,11 @@ wheels = [ [[package]] name = "asgiref" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] [[package]] @@ -455,27 +455,27 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.6" +version = "1.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, ] [[package]] name = "azure-core" -version = "1.38.0" +version = "1.38.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" }, ] [[package]] @@ -640,16 +640,16 @@ wheels = [ [[package]] name = "boto3-stubs" -version = "1.42.37" +version = "1.42.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/b7/15995a1261cb3dccaee7e53e1e27d14fb542c56b95883598b53190e7d979/boto3_stubs-1.42.37.tar.gz", hash = "sha256:1620519a55bbb26cebed95b6d8f26ba96b8ea91dadd05eafc3b8f17a587e2108", size = 100870, upload-time = "2026-01-28T20:56:37.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/3a/3b82edde0a1a0bcf50d331c333adaeb300faa01a4b4955666c0e035b6c64/boto3_stubs-1.42.48.tar.gz", hash = "sha256:99abf298a95ec4f5bef3da6b6211c032fe2bff7d3741bb5f6ae719730da9f799", size = 100892, upload-time = "2026-02-12T21:02:18.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/10/ad4f3ffcdc46df83df4ba06d7692ea0869700537163cd867dd66f966835b/boto3_stubs-1.42.37-py3-none-any.whl", hash = "sha256:07b9ac27196b233b802f8fadff2fa9c01d656927943c618dc862ff00fd592b24", size = 69785, upload-time = "2026-01-28T20:56:29.211Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/fb837b003fc241907d66200cec9fa4c3f838500ebf511560803bebf6449b/boto3_stubs-1.42.48-py3-none-any.whl", hash = "sha256:8757768d1379283afebced52b1b8408ec9bcc7615f986086f3978f8415f98b00", size = 69780, upload-time = "2026-02-12T21:02:11.149Z" }, ] [package.optional-dependencies] @@ -673,14 +673,14 @@ wheels = [ [[package]] name = "botocore-stubs" -version = "1.42.37" +version = "1.42.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-awscrt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/d4/0348543289c57b766622958ebf0b9cc2d9ebd36e803f25e0e55455bbb165/botocore_stubs-1.42.37.tar.gz", hash = "sha256:7357d1876ae198757dbe0a73f887449ffdda18eb075d7d3cc2e22d3580dcb17c", size = 42399, upload-time = "2026-01-28T21:35:52.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/a8/a26608ff39e3a5866c6c79eda10133490205cbddd45074190becece3ff2a/botocore_stubs-1.42.41.tar.gz", hash = "sha256:dbeac2f744df6b814ce83ec3f3777b299a015cbea57a2efc41c33b8c38265825", size = 42411, upload-time = "2026-02-03T20:46:14.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/c8/41d4e54f92865aac2afcae22c6e892659f5172b264d7dec28cf1bb36de7a/botocore_stubs-1.42.37-py3-none-any.whl", hash = "sha256:5a9b2a4062f7cc19e0648508f67d3f1a1fd8d3e0d6f5a0d3244cc9656e54cc67", size = 66761, upload-time = "2026-01-28T21:35:51.749Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/cab7af7f16c0b09347f2ebe7ffda7101132f786acb767666dce43055faab/botocore_stubs-1.42.41-py3-none-any.whl", hash = "sha256:9423110fb0e391834bd2ed44ae5f879d8cb370a444703d966d30842ce2bcb5f0", size = 66759, upload-time = "2026-02-03T20:46:13.02Z" }, ] [[package]] @@ -1137,18 +1137,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - [[package]] name = "cos-python-sdk-v5" version = "1.9.38" @@ -1314,16 +1302,16 @@ wheels = [ [[package]] name = "databricks-sdk" -version = "0.82.0" +version = "0.88.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/ae/dbc1a08b969e48a63e1df2be535caecb16c9eaefd03277065ee1aa2aaf3c/databricks_sdk-0.82.0.tar.gz", hash = "sha256:148399cb0d15d63000e2db2a2a354b3640494cb0ed78e939d3e99a676c3f7ec0", size = 838560, upload-time = "2026-01-29T12:48:30.479Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/ef/4a970033e1ab97a1fea2d93d696bce646339fedf53641935f68573941bae/databricks_sdk-0.88.0.tar.gz", hash = "sha256:1d7d90656b418e488e7f72c872e85a1a1fe4d2d3c0305fd02d5b866f79b769a9", size = 848237, upload-time = "2026-02-12T08:22:04.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0f/f1e2c17c4e5d37eb0135bbc816c9e77d39d34365d8e3a5bf699a9efc39ea/databricks_sdk-0.82.0-py3-none-any.whl", hash = "sha256:927fc575d3019be910839bceba332e7252a0d4e588df64c47e44dd416bd561c8", size = 789223, upload-time = "2026-01-29T12:48:28.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/ca/1635d38f30b48980aee41f63f58fbc6056da733df7cd47b424ac8883a25e/databricks_sdk-0.88.0-py3-none-any.whl", hash = "sha256:fe559a69c5b921feb0e9e15d6c1501549238adee3a035bd9838b64971e42e0ee", size = 798291, upload-time = "2026-02-12T08:22:02.755Z" }, ] [[package]] @@ -1341,7 +1329,7 @@ wheels = [ [[package]] name = "dateparser" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, @@ -1349,9 +1337,9 @@ dependencies = [ { name = "regex" }, { name = "tzlocal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/30/064144f0df1749e7bb5faaa7f52b007d7c2d08ec08fed8411aba87207f68/dateparser-1.2.2.tar.gz", hash = "sha256:986316f17cb8cdc23ea8ce563027c5ef12fc725b6fb1d137c14ca08777c5ecf7", size = 329840, upload-time = "2025-06-26T09:29:23.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/668dfb8c073a5dde3efb80fa382de1502e3b14002fd386a8c1b0b49e92a9/dateparser-1.3.0.tar.gz", hash = "sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5", size = 337152, upload-time = "2026-02-04T16:00:06.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453, upload-time = "2025-06-26T09:29:21.412Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c7/95349670e193b2891176e1b8e5f43e12b31bff6d9994f70e74ab385047f6/dateparser-1.3.0-py3-none-any.whl", hash = "sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a", size = 318688, upload-time = "2026-02-04T16:00:04.652Z" }, ] [[package]] @@ -1767,7 +1755,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" }, @@ -1978,7 +1966,7 @@ wheels = [ [[package]] name = "e2b" -version = "2.12.1" +version = "2.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1992,9 +1980,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/f4/b5b7c8d6d4008e7da36107d84b9faa0ae5ca6faf3dce5f20990c2e7334e3/e2b-2.12.1.tar.gz", hash = "sha256:663c938327c4974344038b9d2927c99b28ab70a88c796bc0cb9a0cbb8b791517", size = 117187, upload-time = "2026-01-27T22:38:18.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/d0/745fe80a0bcc3b61eb81ab4b7640a10245625dc71479ce7ce9da9d9cd896/e2b-2.13.2.tar.gz", hash = "sha256:c0e81a3920091874fdf73c0b8f376b28766212db9f1cea5d8bd56a2e95d2436c", size = 133429, upload-time = "2026-02-09T19:27:58.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f1/787e8f4cc35e96040c4a232d0a4cd84eb1328cf4ab6cd7d47348adc0dc19/e2b-2.12.1-py3-none-any.whl", hash = "sha256:4b021a226afe8f42411a1cd6c22b8c2ff92f37394d7b2461a410c78b1a4504d7", size = 220080, upload-time = "2026-01-27T22:38:17.027Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5b/f83b0397406bb07b9572fc32ecd98502b104a3cfaba85ba4536e77146ccd/e2b-2.13.2-py3-none-any.whl", hash = "sha256:d91d5293bc0dd1917c72a6e6b35e86513607be2666a14ae18c57b921e7864de4", size = 240668, upload-time = "2026-02-09T19:27:57.126Z" }, ] [[package]] @@ -2101,17 +2089,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.0" +version = "0.129.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] [[package]] @@ -2175,11 +2164,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.21.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/71/74364ff065ca78914d8bd90b312fe78ddc5e11372d38bc9cb7104f887ce1/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708", size = 31486, upload-time = "2026-02-13T01:27:15.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/3a18f1e1276810e81477c431009b55eeccebbd7301d28a350b77aacf3c33/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560", size = 21479, upload-time = "2026-02-13T01:27:13.611Z" }, ] [[package]] @@ -2358,11 +2347,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.1.0" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] @@ -2809,33 +2798,33 @@ wheels = [ [[package]] name = "grpcio" -version = "1.76.0" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, ] [[package]] @@ -3064,7 +3053,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.0" +version = "0.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -3076,21 +3065,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, -] - -[[package]] -name = "humanfriendly" -version = "10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, ] [[package]] @@ -3104,14 +3081,14 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.151.4" +version = "6.151.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/03/9fd03d5db09029250e69745c1600edab16fe90947636f77a12ba92d79939/hypothesis-6.151.4.tar.gz", hash = "sha256:658a62da1c3ccb36746ac2f7dc4bb1a6e76bd314e0dc54c4e1aaba2503d5545c", size = 475706, upload-time = "2026-01-29T01:30:14.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/5b/039c095977004f2316225559d591c5a4c62b2e4d7a429db2dd01d37c3ec2/hypothesis-6.151.6.tar.gz", hash = "sha256:755decfa326c8c97a4c8766fe40509985003396442138554b0ae824f9584318f", size = 475846, upload-time = "2026-02-11T04:42:06.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/6d/01ad1b6c3b8cb2bb47eeaa9765dabc27cbe68e3b59f6cff83d5668f57780/hypothesis-6.151.4-py3-none-any.whl", hash = "sha256:a1cf7e0fdaa296d697a68ff3c0b3912c0050f07aa37e7d2ff33a966749d1d9b4", size = 543146, upload-time = "2026-01-29T01:30:12.805Z" }, + { url = "https://files.pythonhosted.org/packages/2c/70/42760b369723f8b5aa6a21e5fae58809f503ca7ebb6da13b99f4de36305a/hypothesis-6.151.6-py3-none-any.whl", hash = "sha256:4e6e933a98c6f606b3e0ada97a750e7fff12277a40260b9300a05e7a5c3c5e2e", size = 543324, upload-time = "2026-02-11T04:42:04.025Z" }, ] [[package]] @@ -3125,17 +3102,19 @@ wheels = [ [[package]] name = "import-linter" -version = "2.9" +version = "2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, + { name = "fastapi" }, { name = "grimp" }, { name = "rich" }, { name = "typing-extensions" }, + { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/ea/9d3ba8e6851d22a073d21ff143a6b23f844dc97f46b41c0dccd26e26d6d3/import_linter-2.9.tar.gz", hash = "sha256:0d7da2a9bb0a534171a592795bd46c8cca86bd6dc6e6e665fa95ba4ed5024215", size = 288196, upload-time = "2025-12-11T11:55:06.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/c4/a83cc1ea9ed0171725c0e2edc11fd929994d4f026028657e8b30d62bca37/import_linter-2.10.tar.gz", hash = "sha256:c6a5057d2dbd32e1854c4d6b60e90dfad459b7ab5356230486d8521f25872963", size = 1149263, upload-time = "2026-02-06T17:57:24.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/3d/657a586f9324ad24538cd797d5c471286e217987e1d0f265575cebe594a9/import_linter-2.9-py3-none-any.whl", hash = "sha256:06403ede04c975cda2ea9050498c16b2021c0261b5cedf47c6c5d8725894b1a2", size = 44899, upload-time = "2025-12-11T11:55:04.87Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e5/4b7b9435eac78ecfd537fa1004a0bcf0f4eac17d3a893f64d38a7bacb51b/import_linter-2.10-py3-none-any.whl", hash = "sha256:cc2ddd7ec0145cbf83f3b25391d2a5dbbf138382aaf80708612497fa6ebc8f60", size = 637081, upload-time = "2026-02-06T17:57:23.386Z" }, ] [[package]] @@ -3239,44 +3218,44 @@ wheels = [ [[package]] name = "jiter" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" }, - { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" }, - { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" }, - { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" }, - { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" }, - { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" }, - { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" }, - { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" }, - { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" }, - { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" }, - { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" }, - { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" }, - { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" }, - { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] [[package]] @@ -3299,11 +3278,11 @@ wheels = [ [[package]] name = "json-repair" -version = "0.55.1" +version = "0.57.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/de/71d6bb078d167c0d0959776cee6b6bb8d2ad843f512a5222d7151dde4955/json_repair-0.55.1.tar.gz", hash = "sha256:b27aa0f6bf2e5bf58554037468690446ef26f32ca79c8753282adb3df25fb888", size = 39231, upload-time = "2026-01-23T09:37:20.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/20/ca8779106afa57878092826efcf8d54929092ef5d9ad9d4b9c33ed2718fc/json_repair-0.57.1.tar.gz", hash = "sha256:6bc8e53226c2cb66cad247f130fe9c6b5d2546d9fe9d7c6cd8c351a9f02e3be6", size = 53575, upload-time = "2026-02-08T10:13:53.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/da/289ba9eb550ae420cfc457926f6c49b87cacf8083ee9927e96921888a665/json_repair-0.55.1-py3-none-any.whl", hash = "sha256:a1bcc151982a12bc3ef9e9528198229587b1074999cfe08921ab6333b0c8e206", size = 29743, upload-time = "2026-01-23T09:37:19.404Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3e/3062565ae270bb1bc25b2c2d1b66d92064d74899c54ad9523b56d00ff49c/json_repair-0.57.1-py3-none-any.whl", hash = "sha256:f72ee964e35de7f5aa0a1e2f3a1c9a6941eb79b619cc98b1ec64bbbfe1c98ba6", size = 38760, upload-time = "2026-02-08T10:13:51.988Z" }, ] [[package]] @@ -3839,14 +3818,14 @@ wheels = [ [[package]] name = "mypy-boto3-bedrock-runtime" -version = "1.42.31" +version = "1.42.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/c9/394b33a2797b78a82af4944dcde156b2f5242b5081996e6cabe809a83089/mypy_boto3_bedrock_runtime-1.42.31.tar.gz", hash = "sha256:a661d1aaadd49660dcf0bcf92beba3546047d06b4744fe5fc5b658ecd165b157", size = 29605, upload-time = "2026-01-20T21:18:32.159Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/bb/65dc1b2c5796a6ab5f60bdb57343bd6c3ecb82251c580eca415c8548333e/mypy_boto3_bedrock_runtime-1.42.42.tar.gz", hash = "sha256:3a4088218478b6fbbc26055c03c95bee4fc04624a801090b3cce3037e8275c8d", size = 29840, upload-time = "2026-02-04T20:53:05.999Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/00/fd3e043e2b9e44229ebf6d4087c7bc8f9fdb87687a999dd1fa06c1a22e4f/mypy_boto3_bedrock_runtime-1.42.31-py3-none-any.whl", hash = "sha256:420961c6c22a9dfdb69bbcc725bff01ae59c6cc347a144e8092aaf9bec1dcdd2", size = 35781, upload-time = "2026-01-20T21:18:29.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/43/7ea062f2228f47b5779dcfa14dab48d6e29f979b35d1a5102b0ba80b9c1b/mypy_boto3_bedrock_runtime-1.42.42-py3-none-any.whl", hash = "sha256:b2d16eae22607d0685f90796b3a0afc78c0b09d45872e00eafd634a31dd9358f", size = 36077, upload-time = "2026-02-04T20:53:01.768Z" }, ] [[package]] @@ -3903,18 +3882,18 @@ wheels = [ [[package]] name = "nodejs-wheel-binaries" -version = "24.13.0" +version = "24.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/f1/73182280e2c05f49a7c2c8dbd46144efe3f74f03f798fb90da67b4a93bbf/nodejs_wheel_binaries-24.13.0.tar.gz", hash = "sha256:766aed076e900061b83d3e76ad48bfec32a035ef0d41bd09c55e832eb93ef7a4", size = 8056, upload-time = "2026-01-14T11:05:33.653Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/d0/81d98b8fddc45332f79d6ad5749b1c7409fb18723545eae75d9b7e0048fb/nodejs_wheel_binaries-24.13.1.tar.gz", hash = "sha256:512659a67449a038231e2e972d49e77049d2cf789ae27db39eff4ab1ca52ac57", size = 8056, upload-time = "2026-02-12T17:31:04.368Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/dc/4d7548aa74a5b446d093f03aff4fb236b570959d793f21c9c42ab6ad870a/nodejs_wheel_binaries-24.13.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:356654baa37bfd894e447e7e00268db403ea1d223863963459a0fbcaaa1d9d48", size = 55133268, upload-time = "2026-01-14T11:05:05.335Z" }, - { url = "https://files.pythonhosted.org/packages/24/8a/8a4454d28339487240dd2232f42f1090e4a58544c581792d427f6239798c/nodejs_wheel_binaries-24.13.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:92fdef7376120e575f8b397789bafcb13bbd22a1b4d21b060d200b14910f22a5", size = 55314800, upload-time = "2026-01-14T11:05:09.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/fb/46c600fcc748bd13bc536a735f11532a003b14f5c4dfd6865f5911672175/nodejs_wheel_binaries-24.13.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3f619ac140e039ecd25f2f71d6e83ad1414017a24608531851b7c31dc140cdfd", size = 59666320, upload-time = "2026-01-14T11:05:12.369Z" }, - { url = "https://files.pythonhosted.org/packages/85/47/d48f11fc5d1541ace5d806c62a45738a1db9ce33e85a06fe4cd3d9ce83f6/nodejs_wheel_binaries-24.13.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dfb31ebc2c129538192ddb5bedd3d63d6de5d271437cd39ea26bf3fe229ba430", size = 60162447, upload-time = "2026-01-14T11:05:16.003Z" }, - { url = "https://files.pythonhosted.org/packages/b1/74/d285c579ae8157c925b577dde429543963b845e69cd006549e062d1cf5b6/nodejs_wheel_binaries-24.13.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fdd720d7b378d5bb9b2710457bbc880d4c4d1270a94f13fbe257198ac707f358", size = 61659994, upload-time = "2026-01-14T11:05:19.68Z" }, - { url = "https://files.pythonhosted.org/packages/ba/97/88b4254a2ff93ed2eaed725f77b7d3d2d8d7973bf134359ce786db894faf/nodejs_wheel_binaries-24.13.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ad6383613f3485a75b054647a09f1cd56d12380d7459184eebcf4a5d403f35c", size = 62244373, upload-time = "2026-01-14T11:05:23.987Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c3/0e13a3da78f08cb58650971a6957ac7bfef84164b405176e53ab1e3584e2/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_amd64.whl", hash = "sha256:605be4763e3ef427a3385a55da5a1bcf0a659aa2716eebbf23f332926d7e5f23", size = 41345528, upload-time = "2026-01-14T11:05:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/a3/f1/0578d65b4e3dc572967fd702221ea1f42e1e60accfb6b0dd8d8f15410139/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_arm64.whl", hash = "sha256:2e3431d869d6b2dbeef1d469ad0090babbdcc8baaa72c01dd3cc2c6121c96af5", size = 39054688, upload-time = "2026-01-14T11:05:30.739Z" }, + { url = "https://files.pythonhosted.org/packages/aa/04/1ffe1838306654fcb50bcf46172567d50c8e27a76f4b9e55a1971fab5c4f/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:360ac9382c651de294c23c4933a02358c4e11331294983f3cf50ca1ac32666b1", size = 54757440, upload-time = "2026-02-12T17:30:35.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/f6/81ad81bc3bd919a20b110130c4fd318c7b6a5abb37eb53daa353ad908012/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:035b718946793986762cdd50deee7f5f1a8f1b0bad0f0cfd57cad5492f5ea018", size = 54932957, upload-time = "2026-02-12T17:30:40.114Z" }, + { url = "https://files.pythonhosted.org/packages/14/be/8e8a2bd50953c4c5b7e0fca07368d287917b84054dc3c93dd26a2940f0f9/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f795e9238438c4225f76fbd01e2b8e1a322116bbd0dc15a7dbd585a3ad97961e", size = 59287257, upload-time = "2026-02-12T17:30:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/58/57/92f6dfa40647702a9fa6d32393ce4595d0fc03c1daa9b245df66cc60e959/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:978328e3ad522571eb163b042dfbd7518187a13968fe372738f90fdfe8a46afc", size = 59781783, upload-time = "2026-02-12T17:30:47.387Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a5/457b984cf675cf86ace7903204b9c36edf7a2d1b4325ddf71eaf8d1027c7/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e1dc893df85299420cd2a5feea0c3f8482a719b5f7f82d5977d58718b8b78b5f", size = 61287166, upload-time = "2026-02-12T17:30:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/3c/99/da515f7bc3bce35cfa6005f0e0c4e3c4042a466782b143112eb393b663be/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0e581ae219a39073dcadd398a2eb648f0707b0f5d68c565586139f919c91cbe9", size = 61870142, upload-time = "2026-02-12T17:30:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c0/22001d2c96d8200834af7d1de5e72daa3266c7270330275104c3d9ddd143/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:d4c969ea0bcb8c8b20bc6a7b4ad2796146d820278f17d4dc20229b088c833e22", size = 41185473, upload-time = "2026-02-12T17:30:57.524Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" }, ] [[package]] @@ -4066,10 +4045,9 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.23.2" +version = "1.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, @@ -4077,21 +4055,19 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, - { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, - { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, + { url = "https://files.pythonhosted.org/packages/d2/88/d9757c62a0f96b5193f8d447a141eefd14498c404cc5caf1a6f3233cf102/onnxruntime-1.24.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:79b3119ab9f4f3817062e6dbe7f4a44937de93905e3a31ba34313d18cb49e7be", size = 17212018, upload-time = "2026-02-05T17:32:13.986Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/b3305c39144e19dbe8791802076b29b4b592b09de03d0e340c1314bfd408/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86bc43e922b1f581b3de26a3dc402149c70e5542fceb5bec6b3a85542dbeb164", size = 15018703, upload-time = "2026-02-05T17:30:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/d273b75fe7825ea3feed321dd540aef33d8a1380ddd8ac3bb70a8ed000fe/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cabe71ca14dcfbf812d312aab0a704507ac909c137ee6e89e4908755d0fc60e", size = 17096352, upload-time = "2026-02-05T17:31:29.057Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/0616101a3938bfe2918ea60b581a9bbba61ffc255c63388abb0885f7ce18/onnxruntime-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:3273c330f5802b64b4103e87b5bbc334c0355fff1b8935d8910b0004ce2f20c8", size = 12493235, upload-time = "2026-02-05T17:32:04.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/30/437de870e4e1c6d237a2ca5e11f54153531270cb5c745c475d6e3d5c5dcf/onnxruntime-1.24.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7307aab9e2e879c0171f37e0eb2808a5b4aec7ba899bb17c5f0cedfc301a8ac2", size = 17211043, upload-time = "2026-02-05T17:32:16.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/60/004401cd86525101ad8aa9eec301327426555d7a77fac89fd991c3c7aae6/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780add442ce2d4175fafb6f3102cdc94243acffa3ab16eacc03dd627cc7b1b54", size = 15016224, upload-time = "2026-02-05T17:30:56.791Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a1/43ad01b806a1821d1d6f98725edffcdbad54856775643718e9124a09bfbe/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6119526eda12613f0d0498e2ae59563c247c370c9cef74c2fc93133dde157", size = 17098191, upload-time = "2026-02-05T17:31:31.87Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/5beb65270864037d5c8fb25cfe6b23c48b618d1f4d06022d425cbf29bd9c/onnxruntime-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0af2f1cfcfff9094971c7eb1d1dfae7ccf81af197493c4dc4643e4342c0946", size = 12493108, upload-time = "2026-02-05T17:32:07.076Z" }, ] [[package]] name = "openai" -version = "2.16.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -4103,9 +4079,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" }, ] [[package]] @@ -4126,7 +4102,7 @@ wheels = [ [[package]] name = "openinference-instrumentation" -version = "0.1.43" +version = "0.1.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openinference-semantic-conventions" }, @@ -4134,18 +4110,18 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/65/f979c42c35406eed5568530bb779a5c34540a42af563bd9049392ecf050e/openinference_instrumentation-0.1.43.tar.gz", hash = "sha256:fa9e8c84f63bb579b48b3e4cea21c10fa5a78961a6db349057ebcd7a33b541dd", size = 23956, upload-time = "2026-01-26T09:10:28.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/d9/c0d3040c0b5dc2b97ad20c35fb3fc1e3f2006bb4b08741ff325efcf3a96a/openinference_instrumentation-0.1.44.tar.gz", hash = "sha256:141953d2da33d54d428dfba2bfebb27ce0517dc43d52e1449a09db72ec7d318e", size = 23959, upload-time = "2026-02-01T01:45:55.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a3/61cb41c04ce05fa86654edac1e6e2c037d1caa828a4bc5bc3cd7a656fb62/openinference_instrumentation-0.1.43-py3-none-any.whl", hash = "sha256:f8b13f39da15202a50823733b245bb296147bb417eb873000c891164c9e68935", size = 30089, upload-time = "2026-01-26T09:10:27.231Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6d/6a19587b26ffa273eb27ba7dd2482013afe3b47c8d9f1f39295216975f9f/openinference_instrumentation-0.1.44-py3-none-any.whl", hash = "sha256:86b2a8931e0f39ecfb739901f8987c654961da03baf3cfa5d5b4f45a96897b2d", size = 30093, upload-time = "2026-02-01T01:45:54.932Z" }, ] [[package]] name = "openinference-semantic-conventions" -version = "0.1.25" +version = "0.1.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/68/81c8a0b90334ff11e4f285e4934c57f30bea3ef0c0b9f99b65e7b80fae3b/openinference_semantic_conventions-0.1.25.tar.gz", hash = "sha256:f0a8c2cfbd00195d1f362b4803518341e80867d446c2959bf1743f1894fce31d", size = 12767, upload-time = "2025-11-05T01:37:45.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/91/f67c1971deaf5b75dea84731393bca2042ff4a46acae9a727dfe267dd568/openinference_semantic_conventions-0.1.26.tar.gz", hash = "sha256:34dae06b40743fb7b846a36fd402810a554b2ec4ee96b9dd8b820663aee4a1f1", size = 12782, upload-time = "2026-02-01T01:09:46.095Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/3d/dd14ee2eb8a3f3054249562e76b253a1545c76adbbfd43a294f71acde5c3/openinference_semantic_conventions-0.1.25-py3-none-any.whl", hash = "sha256:3814240f3bd61f05d9562b761de70ee793d55b03bca1634edf57d7a2735af238", size = 10395, upload-time = "2025-11-05T01:37:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/bb4b9cbd96f72600abec5280cf8ed67bcd849ed19b8bec919aec97adb61c/openinference_semantic_conventions-0.1.26-py3-none-any.whl", hash = "sha256:35b4f487d18ac7d016125c428c0d950dd290e18dafb99787880a9b2e05745f42", size = 10401, upload-time = "2026-02-01T01:09:44.781Z" }, ] [[package]] @@ -4531,40 +4507,40 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.5" +version = "3.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" }, - { url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" }, - { url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" }, - { url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" }, - { url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" }, - { url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" }, - { url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" }, - { url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" }, - { url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" }, - { url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" }, - { url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" }, - { url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, - { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, - { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, ] [[package]] @@ -4727,48 +4703,48 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" +version = "12.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +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/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, + { 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]] name = "platformdirs" -version = "4.5.1" +version = "4.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/25/ccd8e88fcd16a4eb6343a8b4b9635e6f3928a7ebcd82822a14d20e3ca29f/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36", size = 23118, upload-time = "2026-02-12T22:21:53.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983, upload-time = "2026-02-12T22:21:52.237Z" }, ] [[package]] @@ -4842,7 +4818,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.7.0" +version = "7.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4852,9 +4828,9 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/dd/ca6d5a79614af27ededc0dca85e77f42f7704e29f8314819d7ce92b9a7f3/posthog-7.7.0.tar.gz", hash = "sha256:b4f2b1a616e099961f6ab61a5a2f88de62082c26801699e556927d21c00737ef", size = 160766, upload-time = "2026-01-27T21:15:41.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/c9/a7c67c039f23f16a0b87d17561ba2a1c863b01f054a226c92437c539a7b6/posthog-7.8.6.tar.gz", hash = "sha256:6f67e18b5f19bf20d7ef2e1a80fa1ad879a5cd309ca13cfb300f45a8105968c4", size = 169304, upload-time = "2026-02-11T13:59:42.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/3f/41b426ed9ab161d630edec84bacb6664ae62b6e63af1165919c7e11c17d1/posthog-7.7.0-py3-none-any.whl", hash = "sha256:955f42097bf147459653b9102e5f7f9a22e4b6fc9f15003447bd1137fafbc505", size = 185353, upload-time = "2026-01-27T21:15:40.051Z" }, + { url = "https://files.pythonhosted.org/packages/56/c7/41664398a838f52ddfc89141e4c38b88eaa01b9e9a269c5ac184bd8586c6/posthog-7.8.6-py3-none-any.whl", hash = "sha256:21809f73e8e8f09d2bc273b09582f1a9f997b66f51fc626ef5bd3c5bdffd8bcd", size = 194801, upload-time = "2026-02-11T13:59:41.26Z" }, ] [[package]] @@ -4910,14 +4886,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.27.0" +version = "1.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, ] [[package]] @@ -5153,16 +5129,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]] @@ -5254,7 +5230,7 @@ wheels = [ [[package]] name = "pyobvector" -version = "0.2.23" +version = "0.2.24" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiomysql" }, @@ -5264,9 +5240,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "sqlglot" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/14/ea82e5f70c335d2a253ae0a5f182f99abc0319511d565ec887c1d576cfb4/pyobvector-0.2.23.tar.gz", hash = "sha256:c575c84d7aef078d19f7ceeccb7240ea7371940e4e240214ed013b757fbe2b97", size = 73663, upload-time = "2026-01-29T09:29:37.197Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/4d/803a69642ea3375a44f0bce2cb5a9432ee95011fe3000bdcc0acdc52c4bc/pyobvector-0.2.24.tar.gz", hash = "sha256:c395fa8452bfe7b8d0d4111f53afea8c38fc76a61d9047f4a462071b72276bf4", size = 73812, upload-time = "2026-02-05T06:51:42.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/45/29100150b64ec6c2361f11da969bf0a25f33408bae1eba0054abe315922d/pyobvector-0.2.23-py3-none-any.whl", hash = "sha256:04973247f843cbfef548b9d07989190ffc64a56d49c88bf60b3220f0841b33d3", size = 60900, upload-time = "2026-01-29T09:29:35.727Z" }, + { url = "https://files.pythonhosted.org/packages/d9/eb/323474f03164ef35f9902ea68ce34e9d486bd53e636fccfa0ea04f8b5894/pyobvector-0.2.24-py3-none-any.whl", hash = "sha256:70999564817f10d18923f55ff49d1c1e3008bbac6ca46d2070874f4292c85935", size = 61020, upload-time = "2026-02-05T06:51:41.793Z" }, ] [[package]] @@ -5289,11 +5265,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.6.2" +version = "6.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/45/8340de1c752bfda2da912ea0fa8c9a432f7de3f6315e82f1c0847811dff6/pypdf-6.7.0.tar.gz", hash = "sha256:eb95e244d9f434e6cfd157272283339ef586e593be64ee699c620f756d5c3f7e", size = 5299947, upload-time = "2026-02-08T14:47:11.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f1/c92e75a0eb18bb10845e792054ded113010de958b6d4998e201c029417bb/pypdf-6.7.0-py3-none-any.whl", hash = "sha256:62e85036d50839cbdf45b8067c2c1a1b925517514d7cba4cbe8755a6c2829bc9", size = 330557, upload-time = "2026-02-08T14:47:10.111Z" }, ] [[package]] @@ -5327,11 +5303,11 @@ wheels = [ [[package]] name = "pypika" -version = "0.50.0" +version = "0.51.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/fb/b7d5f29108b07c10c69fc3bb72e12f869d55a360a449749fba5a1f903525/pypika-0.50.0.tar.gz", hash = "sha256:2ff66a153adc8d8877879ff2abd5a3b050a5d2adfdf8659d3402076e385e35b3", size = 81033, upload-time = "2026-01-14T12:34:21.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/5b/419c5bb460cb27b52fcd3bc96830255c3265bc1859f55aafa3ff08fae8bd/pypika-0.50.0-py2.py3-none-any.whl", hash = "sha256:ed11b7e259bc38abbcfde00cfb31f8d00aa42ffa51e437b8f5ac2db12b0fe0f4", size = 60577, upload-time = "2026-01-14T12:34:20.078Z" }, + { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, ] [[package]] @@ -5523,14 +5499,14 @@ wheels = [ [[package]] name = "python-engineio" -version = "4.13.0" +version = "4.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "simple-websocket" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/5a/349caac055e03ef9e56ed29fa304846063b1771ee54ab8132bf98b29491e/python_engineio-4.13.0.tar.gz", hash = "sha256:f9c51a8754d2742ba832c24b46ed425fdd3064356914edd5a1e8ffde76ab7709", size = 92194, upload-time = "2025-12-24T22:38:05.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/74/c655a6eda0fd188d490c14142a0f0380655ac7099604e1fbf8fa1a97f0a1/python_engineio-4.13.0-py3-none-any.whl", hash = "sha256:57b94eac094fa07b050c6da59f48b12250ab1cd920765f4849963e3d89ad9de3", size = 59676, upload-time = "2025-12-24T22:38:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, ] [[package]] @@ -5544,11 +5520,11 @@ wheels = [ [[package]] name = "python-iso639" -version = "2025.11.16" +version = "2026.1.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/3b/3e07aadeeb7bbb2574d6aa6ccacbc58b17bd2b1fb6c7196bf96ab0e45129/python_iso639-2025.11.16.tar.gz", hash = "sha256:aabe941267898384415a509f5236d7cfc191198c84c5c6f73dac73d9783f5169", size = 174186, upload-time = "2025-11-16T21:53:37.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/da/701fc47ea3b0579a8ae489d50d5b54f2ef3aeb7768afd31db1d1cfe9f24e/python_iso639-2026.1.31.tar.gz", hash = "sha256:55a1612c15e5fbd3a1fa269a309cbf1e7c13019356e3d6f75bb435ed44c45ddb", size = 174144, upload-time = "2026-01-31T15:04:48.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/2d/563849c31e58eb2e273fa0c391a7d9987db32f4d9152fe6ecdac0a8ffe93/python_iso639-2025.11.16-py3-none-any.whl", hash = "sha256:65f6ac6c6d8e8207f6175f8bf7fff7db486c6dc5c1d8866c2b77d2a923370896", size = 167818, upload-time = "2025-11-16T21:53:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3a/03ee682b04099e6b02b591955851b0347deb2e3691ae850112000c54ba12/python_iso639-2026.1.31-py3-none-any.whl", hash = "sha256:b2c48fa1300af1299dff4f1e1995ad1059996ed9f22270ea2d6d6bdc5fb03d4c", size = 167757, upload-time = "2026-01-31T15:04:46.458Z" }, ] [[package]] @@ -5890,15 +5866,15 @@ wheels = [ [[package]] name = "rich" -version = "14.3.1" +version = "14.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, ] [[package]] @@ -6071,11 +6047,11 @@ flask = [ [[package]] name = "setuptools" -version = "80.10.2" +version = "82.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] @@ -6200,11 +6176,11 @@ wheels = [ [[package]] name = "sqlglot" -version = "28.6.0" +version = "28.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/b6/f188b9616bef49943353f3622d726af30fdb08acbd081deef28ba43ceb48/sqlglot-28.6.0.tar.gz", hash = "sha256:8c0a432a6745c6c7965bbe99a17667c5a3ca1d524a54b31997cf5422b1727f6a", size = 5676522, upload-time = "2026-01-13T17:39:24.389Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/66/b2b300f325227044aa6f511ea7c9f3109a1dc74b13a0897931c1754b504e/sqlglot-28.10.1.tar.gz", hash = "sha256:66e0dae43b4bce23314b80e9aef41b8c88fea0e17ada62de095b45262084a8c5", size = 5739510, upload-time = "2026-02-09T23:36:23.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/a6/21b1e19994296ba4a34bc7abaf4fcb40d7e7787477bdfde58cd843594459/sqlglot-28.6.0-py3-none-any.whl", hash = "sha256:8af76e825dc8456a49f8ce049d69bbfcd116655dda3e53051754789e2edf8eba", size = 575186, upload-time = "2026-01-13T17:39:22.327Z" }, + { url = "https://files.pythonhosted.org/packages/55/ff/5a768b34202e1ee485737bfa167bd84592585aa40383f883a8e346d767cc/sqlglot-28.10.1-py3-none-any.whl", hash = "sha256:214aef51fd4ce16407022f81cfc80c173409dab6d0f6ae18c52b43f43b31d4dd", size = 597053, upload-time = "2026-02-09T23:36:21.385Z" }, ] [[package]] @@ -6389,11 +6365,11 @@ wheels = [ [[package]] name = "tenacity" -version = "9.1.2" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] @@ -6526,14 +6502,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/b3/13451226f564f88d9 [[package]] name = "tqdm" -version = "4.67.1" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] @@ -6559,41 +6535,41 @@ wheels = [ [[package]] name = "ty" -version = "0.0.14" +version = "0.0.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/18/77f84d89db54ea0d1d1b09fa2f630ac4c240c8e270761cb908c06b6e735c/ty-0.0.16.tar.gz", hash = "sha256:a999b0db6aed7d6294d036ebe43301105681e0c821a19989be7c145805d7351c", size = 5129637, upload-time = "2026-02-10T20:24:16.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" }, - { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" }, - { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" }, - { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" }, - { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" }, - { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" }, - { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/909ebcc7f59eaf8a2c18fb54bfcf1c106f99afb3e5460058d4b46dec7b20/ty-0.0.16-py3-none-linux_armv6l.whl", hash = "sha256:6d8833b86396ed742f2b34028f51c0e98dbf010b13ae4b79d1126749dc9dab15", size = 10113870, upload-time = "2026-02-10T20:24:11.864Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2c/b963204f3df2fdbf46a4a1ea4a060af9bb676e065d59c70ad0f5ae0dbae8/ty-0.0.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934c0055d3b7f1cf3c8eab78c6c127ef7f347ff00443cef69614bda6f1502377", size = 9936286, upload-time = "2026-02-10T20:24:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4d/3d78294f2ddfdded231e94453dea0e0adef212b2bd6536296039164c2a3e/ty-0.0.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b55e8e8733b416d914003cd22e831e139f034681b05afed7e951cc1a5ea1b8d4", size = 9442660, upload-time = "2026-02-10T20:24:02.704Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/ce48c0541e3b5749b0890725870769904e6b043e077d4710e5325d5cf807/ty-0.0.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feccae8f4abd6657de111353bd604f36e164844466346eb81ffee2c2b06ea0f0", size = 9934506, upload-time = "2026-02-10T20:24:35.818Z" }, + { url = "https://files.pythonhosted.org/packages/84/16/3b29de57e1ec6e56f50a4bb625ee0923edb058c5f53e29014873573a00cd/ty-0.0.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cad5e29d8765b92db5fa284940ac57149561f3f89470b363b9aab8a6ce553b0", size = 9933099, upload-time = "2026-02-10T20:24:43.003Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a1/e546995c25563d318c502b2f42af0fdbed91e1fc343708241e2076373644/ty-0.0.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86f28797c7dc06f081238270b533bf4fc8e93852f34df49fb660e0b58a5cda9a", size = 10438370, upload-time = "2026-02-10T20:24:33.44Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/22d301a4b2cce0f75ae84d07a495f87da193bcb68e096d43695a815c4708/ty-0.0.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be971a3b42bcae44d0e5787f88156ed2102ad07558c05a5ae4bfd32a99118e66", size = 10992160, upload-time = "2026-02-10T20:24:25.574Z" }, + { url = "https://files.pythonhosted.org/packages/6f/40/f1892b8c890db3f39a1bab8ec459b572de2df49e76d3cad2a9a239adcde9/ty-0.0.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c9f982b7c4250eb91af66933f436b3a2363c24b6353e94992eab6551166c8b7", size = 10717892, upload-time = "2026-02-10T20:24:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1b/caf9be8d0c738983845f503f2e92ea64b8d5fae1dd5ca98c3fca4aa7dadc/ty-0.0.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d122edf85ce7bdf6f85d19158c991d858fc835677bd31ca46319c4913043dc84", size = 10510916, upload-time = "2026-02-10T20:24:00.252Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/28980f5c7e1f4c9c44995811ea6a36f2fcb205232a6ae0f5b60b11504621/ty-0.0.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:497ebdddbb0e35c7758ded5aa4c6245e8696a69d531d5c9b0c1a28a075374241", size = 9908506, upload-time = "2026-02-10T20:24:28.133Z" }, + { url = "https://files.pythonhosted.org/packages/f7/80/8672306596349463c21644554f935ff8720679a14fd658fef658f66da944/ty-0.0.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e1e0ac0837bde634b030243aeba8499383c0487e08f22e80f5abdacb5b0bd8ce", size = 9949486, upload-time = "2026-02-10T20:24:18.62Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8a/d8747d36f30bd82ea157835f5b70d084c9bb5d52dd9491dba8a149792d6a/ty-0.0.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1216c9bcca551d9f89f47a817ebc80e88ac37683d71504e5509a6445f24fd024", size = 10145269, upload-time = "2026-02-10T20:24:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4c/753535acc7243570c259158b7df67e9c9dd7dab9a21ee110baa4cdcec45d/ty-0.0.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:221bbdd2c6ee558452c96916ab67fcc465b86967cf0482e19571d18f9c831828", size = 10608644, upload-time = "2026-02-10T20:24:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/3e/05/8e8db64cf45a8b16757e907f7a3bfde8d6203e4769b11b64e28d5bdcd79a/ty-0.0.16-py3-none-win32.whl", hash = "sha256:d52c4eb786be878e7514cab637200af607216fcc5539a06d26573ea496b26512", size = 9582579, upload-time = "2026-02-10T20:24:30.406Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/45759faea132cd1b2a9ff8374e42ba03d39d076594fbb94f3e0e2c226c62/ty-0.0.16-py3-none-win_amd64.whl", hash = "sha256:f572c216aa8ecf79e86589c6e6d4bebc01f1f3cb3be765c0febd942013e1e73a", size = 10436043, upload-time = "2026-02-10T20:23:57.51Z" }, + { url = "https://files.pythonhosted.org/packages/7f/02/70a491802e7593e444137ed4e41a04c34d186eb2856f452dd76b60f2e325/ty-0.0.16-py3-none-win_arm64.whl", hash = "sha256:430eadeb1c0de0c31ef7bef9d002bdbb5f25a31e3aad546f1714d76cd8da0a87", size = 9915122, upload-time = "2026-02-10T20:24:14.285Z" }, ] [[package]] name = "typer" -version = "0.21.1" +version = "0.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/e6/44e073787aa57cd71c151f44855232feb0f748428fd5242d7366e3c4ae8b/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc", size = 120181, upload-time = "2026-02-11T15:22:18.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ed/d6fca788b51d0d4640c4bc82d0e85bad4b49809bca36bf4af01b4dcb66a7/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913", size = 56668, upload-time = "2026-02-11T15:22:21.075Z" }, ] [[package]] @@ -6710,15 +6686,15 @@ wheels = [ [[package]] name = "types-gevent" -version = "25.9.0.20251102" +version = "25.9.0.20251228" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-greenlet" }, { name = "types-psutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/21/552d818a475e1a31780fb7ae50308feb64211a05eb403491d1a34df95e5f/types_gevent-25.9.0.20251102.tar.gz", hash = "sha256:76f93513af63f4577bb4178c143676dd6c4780abc305f405a4e8ff8f1fa177f8", size = 38096, upload-time = "2025-11-02T03:07:42.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/85/c5043c4472f82c8ee3d9e0673eb4093c7d16770a26541a137a53a1d096f6/types_gevent-25.9.0.20251228.tar.gz", hash = "sha256:423ef9891d25c5a3af236c3e9aace4c444c86ff773fe13ef22731bc61d59abef", size = 38063, upload-time = "2025-12-28T03:28:28.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/a1/776d2de31a02123f225aaa790641113ae47f738f6e8e3091d3012240a88e/types_gevent-25.9.0.20251102-py3-none-any.whl", hash = "sha256:0f14b9977cb04bf3d94444b5ae6ec5d78ac30f74c4df83483e0facec86f19d8b", size = 55592, upload-time = "2025-11-02T03:07:41.003Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/a2d6b652ab5a26318b68cafd58c46fafb9b15c5313d2d76a70b838febb4b/types_gevent-25.9.0.20251228-py3-none-any.whl", hash = "sha256:e2e225af4fface9241c16044983eb2fc3993f2d13d801f55c2932848649b7f2f", size = 55486, upload-time = "2025-12-28T03:28:27.382Z" }, ] [[package]] @@ -6968,11 +6944,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "80.10.0.20260124" +version = "82.0.0.20260210" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/7e/116539b9610585e34771611e33c88a4c706491fa3565500f5a63139f8731/types_setuptools-80.10.0.20260124.tar.gz", hash = "sha256:1b86d9f0368858663276a0cbe5fe5a9722caf94b5acde8aba0399a6e90680f20", size = 43299, upload-time = "2026-01-24T03:18:39.527Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/90/796ac8c774a7f535084aacbaa6b7053d16fff5c630eff87c3ecff7896c37/types_setuptools-82.0.0.20260210.tar.gz", hash = "sha256:d9719fbbeb185254480ade1f25327c4654f8c00efda3fec36823379cebcdee58", size = 44768, upload-time = "2026-02-10T04:22:02.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/7f/016dc5cc718ec6ccaa84fb73ed409ef1c261793fd5e637cdfaa18beb40a9/types_setuptools-80.10.0.20260124-py3-none-any.whl", hash = "sha256:efed7e044f01adb9c2806c7a8e1b6aa3656b8e382379b53d5f26ee3db24d4c01", size = 64333, upload-time = "2026-01-24T03:18:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/3e/54/3489432b1d9bc713c9d8aa810296b8f5b0088403662959fb63a8acdbd4fc/types_setuptools-82.0.0.20260210-py3-none-any.whl", hash = "sha256:5124a7daf67f195c6054e0f00f1d97c69caad12fdcf9113eba33eff0bce8cd2b", size = 68433, upload-time = "2026-02-10T04:22:00.876Z" }, ] [[package]] @@ -7021,14 +6997,14 @@ wheels = [ [[package]] name = "types-tqdm" -version = "4.67.0.20250809" +version = "4.67.3.20260205" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/cf498fc630d9fdaf2428b93e60b0e67b08008fec22b78716b8323cf644dc/types_tqdm-4.67.0.20250809.tar.gz", hash = "sha256:02bf7ab91256080b9c4c63f9f11b519c27baaf52718e5fdab9e9606da168d500", size = 17200, upload-time = "2025-08-09T03:17:43.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/46/790b9872523a48163bdda87d47849b4466017640e5259d06eed539340afd/types_tqdm-4.67.3.20260205.tar.gz", hash = "sha256:f3023682d4aa3bbbf908c8c6bb35f35692d319460d9bbd3e646e8852f3dd9f85", size = 17597, upload-time = "2026-02-05T04:03:19.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/13/3ff0781445d7c12730befce0fddbbc7a76e56eb0e7029446f2853238360a/types_tqdm-4.67.0.20250809-py3-none-any.whl", hash = "sha256:1a73053b31fcabf3c1f3e2a9d5ecdba0f301bde47a418cd0e0bdf774827c5c57", size = 24020, upload-time = "2025-08-09T03:17:42.453Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/7f761868dbaa328392356fab30c18ab90d14cce86b269e7e63328f29d4a3/types_tqdm-4.67.3.20260205-py3-none-any.whl", hash = "sha256:85c31731e81dc3c5cecc34c6c8b2e5166fafa722468f58840c2b5ac6a8c5c173", size = 23894, upload-time = "2026-02-05T04:03:18.48Z" }, ] [[package]] @@ -7134,7 +7110,7 @@ wheels = [ [[package]] name = "unstructured" -version = "0.18.31" +version = "0.18.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -7160,9 +7136,9 @@ dependencies = [ { name = "unstructured-client" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/5f/64285bd69a538bc28753f1423fcaa9d64cd79a9e7c097171b1f0d27e9cdb/unstructured-0.18.31.tar.gz", hash = "sha256:af4bbe32d1894ae6e755f0da6fc0dd307a1d0adeebe0e7cc6278f6cf744339ca", size = 1707700, upload-time = "2026-01-27T15:33:05.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/65/b73d84ede08fc2defe9c59d85ebf91f78210a424986586c6e39784890c8e/unstructured-0.18.32.tar.gz", hash = "sha256:40a7cf4a4a7590350bedb8a447e37029d6e74b924692576627b4edb92d70e39d", size = 1707730, upload-time = "2026-02-10T22:28:22.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/4a/9c43f39d9e443c9bc3f2e379b305bca27110adc653b071221b3132c18de5/unstructured-0.18.31-py3-none-any.whl", hash = "sha256:fab4641176cb9b192ed38048758aa0d9843121d03626d18f42275afb31e5b2d3", size = 1794889, upload-time = "2026-01-27T15:33:03.136Z" }, + { url = "https://files.pythonhosted.org/packages/68/e7/35298355bdb917293dc3e179304e737ce3fe14247fb5edf09fddddc98409/unstructured-0.18.32-py3-none-any.whl", hash = "sha256:c832ecdf467f5a869cc5e91428459e4b9ed75a16156ce3fab8f41ff64d840bc7", size = 1794965, upload-time = "2026-02-10T22:28:20.301Z" }, ] [package.optional-dependencies] @@ -7184,7 +7160,7 @@ pptx = [ [[package]] name = "unstructured-client" -version = "0.42.8" +version = "0.42.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -7193,11 +7169,12 @@ dependencies = [ { name = "httpx" }, { name = "pydantic" }, { name = "pypdf" }, + { name = "pypdfium2" }, { name = "requests-toolbelt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/67/6afb5337e97566a9dc0337606223893ce01f175bd17bf05844a816581b69/unstructured_client-0.42.8.tar.gz", hash = "sha256:663655548ed5c205efb48b7f38ca0906998b33571512f7c53c60aa811e514464", size = 94400, upload-time = "2026-01-14T21:54:03.373Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/3e/dd81a2065e50b5b013c9d12a0b6346f86b3252d43a65269a72761e234bcb/unstructured_client-0.42.10.tar.gz", hash = "sha256:e516299c27178865dbd4e2bbd6f00a820ddd40323b2578f303106732fc576217", size = 94726, upload-time = "2026-02-03T18:01:50.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/18/d792b297937459ef54e3972b08ce3b5bdd4018d053837a8cfb3c40dd1c49/unstructured_client-0.42.8-py3-none-any.whl", hash = "sha256:6dbdb62d36554a5cbe61dc1b6ef0c8b11a46cc61e2602c2dc22975ba78028214", size = 219970, upload-time = "2026-01-14T21:54:01.206Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/bb9b9e7df245549e2daae58b54fdd612f016111c5b06df3c66965ac8545e/unstructured_client-0.42.10-py3-none-any.whl", hash = "sha256:0034ddcd988e17db83080db26fb36f23c24ace34afedeb267dab245029f8f7a2", size = 220161, upload-time = "2026-02-03T18:01:49.487Z" }, ] [[package]] @@ -7321,7 +7298,7 @@ wheels = [ [[package]] name = "wandb" -version = "0.24.0" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -7335,17 +7312,17 @@ dependencies = [ { name = "sentry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/7e/aad6e943012ea4d88f3a037f1a5a7c6898263c60fbef8c9cdb95a8ff9fd9/wandb-0.24.0.tar.gz", hash = "sha256:4715a243b3d460b6434b9562e935dfd9dfdf5d6e428cfb4c3e7ce4fd44460ab3", size = 44197947, upload-time = "2026-01-13T22:59:59.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/60/d94952549920469524b689479c864c692ca47eca4b8c2fe3389b64a58778/wandb-0.25.0.tar.gz", hash = "sha256:45840495a288e34245d69d07b5a0b449220fbc5b032e6b51c4f92ec9026d2ad1", size = 43951335, upload-time = "2026-02-13T00:17:45.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/8a/efec186dcc5dcf3c806040e3f33e58997878b2d30b87aa02b26f046858b6/wandb-0.24.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:aa9777398ff4b0f04c41359f7d1b95b5d656cb12c37c63903666799212e50299", size = 21464901, upload-time = "2026-01-13T22:59:31.86Z" }, - { url = "https://files.pythonhosted.org/packages/ed/84/fadf0d5f1d86c3ba662d2b33a15d2b1f08ff1e4e196c77e455f028b0fda2/wandb-0.24.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:0423fbd58c3926949724feae8aab89d20c68846f9f4f596b80f9ffe1fc298130", size = 22697817, upload-time = "2026-01-13T22:59:35.267Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5f/e3124e68d02b30c62856175ce714e07904730be06eecb00f66bb1a59aacf/wandb-0.24.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2b25fc0c123daac97ed32912ac55642c65013cc6e3a898e88ca2d917fc8eadc0", size = 21118798, upload-time = "2026-01-13T22:59:38.453Z" }, - { url = "https://files.pythonhosted.org/packages/22/a1/8d68a914c030e897c306c876d47c73aa5d9ca72be608971290d3a5749570/wandb-0.24.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9485344b4667944b5b77294185bae8469cfa4074869bec0e74f54f8492234cc2", size = 22849954, upload-time = "2026-01-13T22:59:41.265Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/3e68841a4282a4fb6a8935534e6064acc6c9708e8fb76953ec73bbc72a5e/wandb-0.24.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:51b2b9a9d7d6b35640f12a46a48814fd4516807ad44f586b819ed6560f8de1fd", size = 21160339, upload-time = "2026-01-13T22:59:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/16/e5/d851868ce5b4b437a7cc90405979cd83809790e4e2a2f1e454f63f116e52/wandb-0.24.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:11f7e7841f31eff82c82a677988889ad3aa684c6de61ff82145333b5214ec860", size = 22936978, upload-time = "2026-01-13T22:59:46.911Z" }, - { url = "https://files.pythonhosted.org/packages/d2/34/43b7f18870051047ce6fe18e7eb24ba7ebdc71663a8f1c58e31e855eb8ac/wandb-0.24.0-py3-none-win32.whl", hash = "sha256:42af348998b00d4309ae790c5374040ac6cc353ab21567f4e29c98c9376dee8e", size = 22118243, upload-time = "2026-01-13T22:59:49.555Z" }, - { url = "https://files.pythonhosted.org/packages/a1/92/909c81173cf1399111f57f9ca5399a8f165607b024e406e080178c878f70/wandb-0.24.0-py3-none-win_amd64.whl", hash = "sha256:32604eddcd362e1ed4a2e2ce5f3a239369c4a193af223f3e66603481ac91f336", size = 22118246, upload-time = "2026-01-13T22:59:52.126Z" }, - { url = "https://files.pythonhosted.org/packages/87/85/a845aefd9c2285f98261fa6ffa0a14466366c1ac106d35bc84b654c0ad7f/wandb-0.24.0-py3-none-win_arm64.whl", hash = "sha256:e0f2367552abfca21b0f3a03405fbf48f1e14de9846e70f73c6af5da57afd8ef", size = 20077678, upload-time = "2026-01-13T22:59:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7d/0c131db3ec9deaabbd32263d90863cbfbe07659527e11c35a5c738cecdc5/wandb-0.25.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:5eecb3c7b5e60d1acfa4b056bfbaa0b79a482566a9db58c9f99724b3862bc8e5", size = 23287536, upload-time = "2026-02-13T00:17:20.265Z" }, + { url = "https://files.pythonhosted.org/packages/c3/95/31bb7f76a966ec87495e5a72ac7570685be162494c41757ac871768dbc4f/wandb-0.25.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:daeedaadb183dc466e634fba90ab2bab1d4e93000912be0dee95065a0624a3fd", size = 25196062, upload-time = "2026-02-13T00:17:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/258cdedbf30cebc692198a774cf0ef945b7ed98ee64bdaf62621281c95d8/wandb-0.25.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5e0127dbcef13eea48f4b84268da7004d34d3120ebc7b2fa9cefb72b49dbb825", size = 22799744, upload-time = "2026-02-13T00:17:26.437Z" }, + { url = "https://files.pythonhosted.org/packages/de/91/ec9465d014cfd199c5b2083d271d31b3c2aedeae66f3d8a0712f7f54bdf3/wandb-0.25.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6c4c38077836f9b7569a35b0e1dcf1f0c43616fcd936d182f475edbfea063665", size = 25262839, upload-time = "2026-02-13T00:17:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/cb2d1c7143f534544147fb53fe87944508b8cb9a058bc5b6f8a94adbee15/wandb-0.25.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6edd8948d305cb73745bf564b807bd73da2ccbd47c548196b8a362f7df40aed8", size = 22853714, upload-time = "2026-02-13T00:17:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/d7/94/68163f70c1669edcf130822aaaea782d8198b5df74443eca0085ec596774/wandb-0.25.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ada6f08629bb014ad6e0a19d5dec478cdaa116431baa3f0a4bf4ab8d9893611f", size = 25358037, upload-time = "2026-02-13T00:17:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fb/9578eed2c01b2fc6c8b693da110aa9c73a33d7bb556480f5cfc42e48c94e/wandb-0.25.0-py3-none-win32.whl", hash = "sha256:020b42ca4d76e347709d65f59b30d4623a115edc28f462af1c92681cb17eae7c", size = 24604118, upload-time = "2026-02-13T00:17:37.641Z" }, + { url = "https://files.pythonhosted.org/packages/25/97/460f6cb738aaa39b4eb2e6b4c630b2ae4321cdd70a79d5955ea75a878981/wandb-0.25.0-py3-none-win_amd64.whl", hash = "sha256:78307ac0b328f2dc334c8607bec772851215584b62c439eb320c4af4fb077a00", size = 24604122, upload-time = "2026-02-13T00:17:39.991Z" }, + { url = "https://files.pythonhosted.org/packages/27/6c/5847b4dda1dfd52630dac08711d4348c69ed657f0698fc2d949c7f7a6622/wandb-0.25.0-py3-none-win_arm64.whl", hash = "sha256:c6174401fd6fb726295e98d57b4231c100eca96bd17de51bfc64038a57230aaf", size = 21785298, upload-time = "2026-02-13T00:17:42.475Z" }, ] [[package]] @@ -7403,11 +7380,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/6e/62daec357285b927e82263a81f3b4c1790215bc77c42530ce4a69d501a43/wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333", size = 246585, upload-time = "2026-01-27T01:31:44.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 94810be1ab..893370b9ef 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1097,6 +1097,8 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false WORKFLOW_LOG_RETENTION_DAYS=30 # Batch size for workflow log cleanup operations (default: 100) WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 +# Comma-separated list of workflow IDs to clean logs for +WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= # Aliyun SLS Logstore Configuration # Aliyun Access Key ID diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e17c7128fe..f3e9f4ba32 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -475,6 +475,7 @@ x-shared-env: &shared-api-worker-env WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false} WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30} WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100} + WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: ${WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS:-} ALIYUN_SLS_ACCESS_KEY_ID: ${ALIYUN_SLS_ACCESS_KEY_ID:-} ALIYUN_SLS_ACCESS_KEY_SECRET: ${ALIYUN_SLS_ACCESS_KEY_SECRET:-} ALIYUN_SLS_ENDPOINT: ${ALIYUN_SLS_ENDPOINT:-} diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx new file mode 100644 index 0000000000..c2866cab2b --- /dev/null +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -0,0 +1,460 @@ +/** + * 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('@headlessui/react') + return { + ...actual, + Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => ( +
+ {typeof children === 'function' ? children({ open: true }) : children} +
+ ), + PopoverButton: ({ children, className, ref: _ref, ...rest }: Record) => ( + + ), + PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => ( +
+ {typeof children === 'function' ? children({ close: vi.fn() }) : children} +
+ ), + Transition: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + let Component: React.ComponentType> | null = null + loader().then((mod) => { + Component = mod.default as React.ComponentType> + }).catch(() => {}) + const Wrapper = (props: Record) => { + if (Component) + return + 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) => 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('use-context-selector') + return { + ...actual, + useContext: () => ({ notify: mockNotify }), + } +}) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record) => 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) => { + if (!show) + return null + return ( +
+ {appName as string} + + +
+ ) + }, +})) + +vi.mock('@/app/components/app/duplicate-modal', () => ({ + default: ({ show, onConfirm, onHide }: Record) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/app/switch-app-modal', () => ({ + default: ({ show, onClose, onSuccess }: Record) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onConfirm, onCancel, title }: Record) => { + if (!isShow) + return null + return ( +
+ {title as string} + + +
+ ) + }, +})) + +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ onConfirm, onClose }: Record) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: ({ onConfirm, onClose }: Record) => ( +
+ + +
+ ), +})) + +const createMockApp = (overrides: Partial = {}): 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, + runtime_type: overrides.runtime_type ?? 'classic', + 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) => { + return render() +} + +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() + }) + } + }) + }) +}) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx new file mode 100644 index 0000000000..163f4e8226 --- /dev/null +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -0,0 +1,440 @@ +/** + * 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) => { + return
+ } + 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) => 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) => 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('ahooks') + const React = await vi.importActual('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 => ({ + 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, + runtime_type: overrides.runtime_type ?? 'classic', + 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) => { + return render( + + + , + ) +} + +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( + + + , + ) + + 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( + + + , + ) + + 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( + + + , + ) + + rerender( + + + , + ) + + expect(mockRefetch).toHaveBeenCalled() + }) + }) +}) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx new file mode 100644 index 0000000000..9a4a669c41 --- /dev/null +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -0,0 +1,466 @@ +/** + * 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) => 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) => 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('ahooks') + const React = await vi.importActual('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> | null = null + loader().then((mod) => { + Component = mod.default as React.ComponentType> + }).catch(() => {}) + const Wrapper = (props: Record) => { + if (Component) + return + return null + } + Wrapper.displayName = 'DynamicWrapper' + return Wrapper + }, +})) + +vi.mock('@/app/components/app/create-app-modal', () => ({ + default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record) => { + if (!show) + return null + return ( +
+ + {!!onCreateFromTemplate && ( + + )} + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-app-dialog', () => ({ + default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record) => { + if (!show) + return null + return ( +
+ + {!!onCreateFromBlank && ( + + )} + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ + default: ({ show, onClose, onSuccess }: Record) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, + CreateFromDSLModalTab: { + FROM_URL: 'from-url', + FROM_FILE: 'from-file', + }, +})) + +const createMockApp = (overrides: Partial = {}): 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, + runtime_type: overrides.runtime_type ?? 'classic', + 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( + + + , + ) +} + +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() + }) + }) + }) +}) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx new file mode 100644 index 0000000000..4891760df4 --- /dev/null +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -0,0 +1,991 @@ +import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import AnnotationFull from '@/app/components/billing/annotation-full' +import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' +import AppsFull from '@/app/components/billing/apps-full-in-dialog' +import Billing from '@/app/components/billing/billing-page' +import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config' +import HeaderBillingBtn from '@/app/components/billing/header-billing-btn' +import PlanComp from '@/app/components/billing/plan' +import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import PriorityLabel from '@/app/components/billing/priority-label' +import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal' +import { Plan } from '@/app/components/billing/type' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import VectorSpaceFull from '@/app/components/billing/vector-space-full' + +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), + useModalContextSelector: (selector: (s: Record) => unknown) => + selector({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', + useGetPricingPageLanguage: () => 'en', +})) + +// ─── Service mocks ────────────────────────────────────────────────────────── +const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' }) +vi.mock('@/service/use-billing', () => ({ + useBillingUrl: () => ({ + data: 'https://billing.example.com', + isFetching: false, + refetch: mockRefetch, + }), + useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }), + isPending: false, + }), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +const mockRouterPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── External component mocks ─────────────────────────────────────────────── +vi.mock('@/app/education-apply/verify-state-modal', () => ({ + default: ({ isShow }: { isShow: boolean }) => + isShow ?
: null, +})) + +vi.mock('@/app/components/header/utils/util', () => ({ + mailToSupport: () => 'mailto:support@test.com', +})) + +// ─── Test data factories ──────────────────────────────────────────────────── +type PlanOverrides = { + type?: string + usage?: Partial + total?: Partial + reset?: Partial +} + +const createPlanData = (overrides: PlanOverrides = {}) => ({ + ...defaultPlan, + ...overrides, + type: overrides.type ?? defaultPlan.type, + usage: { ...defaultPlan.usage, ...overrides.usage }, + total: { ...defaultPlan.total, ...overrides.total }, + reset: { ...defaultPlan.reset, ...overrides.reset }, +}) + +const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record = {}) => { + mockProviderCtx = { + plan: createPlanData(planOverrides), + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + ...extra, + } +} + +const setupAppContext = (overrides: Record = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'test@example.com' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...overrides, + } +} + +// Vitest hoists vi.mock() calls, so imports above will use mocked modules + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. Billing Page + Plan Component Integration +// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar +// ═══════════════════════════════════════════════════════════════════════════ +describe('Billing Page + Plan Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + // Verify that the billing page renders PlanComp with all 7 usage items + describe('Rendering complete plan information', () => { + it('should display all 7 usage metrics for sandbox plan', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { + buildApps: 3, + teamMembers: 1, + documentsUploadQuota: 10, + vectorSpace: 20, + annotatedResponse: 5, + triggerEvents: 1000, + apiRateLimit: 2000, + }, + total: { + buildApps: 5, + teamMembers: 1, + documentsUploadQuota: 50, + vectorSpace: 50, + annotatedResponse: 10, + triggerEvents: 3000, + apiRateLimit: 5000, + }, + }) + + render() + + // Plan name + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + + // All 7 usage items should be visible + expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument() + expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument() + }) + + it('should display usage values as "usage / total" format', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 3, teamMembers: 1 }, + total: { buildApps: 5, teamMembers: 1 }, + }) + + render() + + // Check that the buildApps usage fraction "3 / 5" is rendered + const usageContainers = screen.getAllByText('3') + expect(usageContainers.length).toBeGreaterThan(0) + const totalContainers = screen.getAllByText('5') + expect(totalContainers.length).toBeGreaterThan(0) + }) + + it('should show "unlimited" for infinite quotas (professional API rate limit)', () => { + setupProviderContext({ + type: Plan.professional, + total: { apiRateLimit: NUM_INFINITE }, + }) + + render() + + expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument() + }) + + it('should display reset days for trigger events when applicable', () => { + setupProviderContext({ + type: Plan.professional, + total: { triggerEvents: 20000 }, + reset: { triggerEvents: 7 }, + }) + + render() + + // Reset text should be visible + expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument() + }) + }) + + // Verify billing URL button visibility and behavior + describe('Billing URL button', () => { + it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => { + setupProviderContext({ type: Plan.sandbox }) + setupAppContext({ isCurrentWorkspaceManager: true }) + + render() + + expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument() + expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument() + }) + + it('should hide billing button when user is not workspace manager', () => { + setupProviderContext({ type: Plan.sandbox }) + setupAppContext({ isCurrentWorkspaceManager: false }) + + render() + + expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() + }) + + it('should hide billing button when billing is disabled', () => { + setupProviderContext({ type: Plan.sandbox }, { enableBilling: false }) + + render() + + expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Plan Type Display Integration +// Tests that different plan types render correct visual elements +// ═══════════════════════════════════════════════════════════════════════════ +describe('Plan Type Display Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should render sandbox plan with upgrade button (premium badge)', () => { + setupProviderContext({ type: Plan.sandbox }) + + render() + + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument() + // Sandbox shows premium badge upgrade button (not plain) + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should render professional plan with plain upgrade button', () => { + setupProviderContext({ type: Plan.professional }) + + render() + + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + // Professional shows plain button because it's not team + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should render team plan with plain-style upgrade button', () => { + setupProviderContext({ type: Plan.team }) + + render() + + expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument() + // Team plan has isPlain=true, so shows "upgradeBtn.plain" text + expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument() + }) + + it('should not render upgrade button for enterprise plan', () => { + setupProviderContext({ type: Plan.enterprise }) + + render() + + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument() + }) + + it('should show education verify button when enableEducationPlan is true and not yet verified', () => { + setupProviderContext({ type: Plan.sandbox }, { + enableEducationPlan: true, + isEducationAccount: false, + }) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Upgrade Flow Integration +// Tests the flow: UpgradeBtn click → setShowPricingModal +// and PlanUpgradeModal → close + trigger pricing +// ═══════════════════════════════════════════════════════════════════════════ +describe('Upgrade Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + setupProviderContext({ type: Plan.sandbox }) + }) + + // UpgradeBtn triggers pricing modal + describe('UpgradeBtn triggers pricing modal', () => { + it('should call setShowPricingModal when clicking premium badge upgrade button', async () => { + const user = userEvent.setup() + + render() + + const badgeText = screen.getByText(/upgradeBtn\.encourage/i) + await user.click(badgeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should call setShowPricingModal when clicking plain upgrade button', async () => { + const user = userEvent.setup() + + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should use custom onClick when provided instead of setShowPricingModal', async () => { + const customOnClick = vi.fn() + const user = userEvent.setup() + + render() + + const badgeText = screen.getByText(/upgradeBtn\.encourage/i) + await user.click(badgeText) + + expect(customOnClick).toHaveBeenCalledTimes(1) + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + + it('should fire gtag event with loc parameter when clicked', async () => { + const mockGtag = vi.fn() + ;(window as unknown as Record).gtag = mockGtag + const user = userEvent.setup() + + render() + + const badgeText = screen.getByText(/upgradeBtn\.encourage/i) + await user.click(badgeText) + + expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' }) + delete (window as unknown as Record).gtag + }) + }) + + // PlanUpgradeModal integration: close modal and trigger pricing + describe('PlanUpgradeModal upgrade flow', () => { + it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + , + ) + + // The modal should show title and description + expect(screen.getByText('Upgrade Required')).toBeInTheDocument() + expect(screen.getByText('You need a better plan')).toBeInTheDocument() + + // Click the upgrade button inside the modal + const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeText) + + // Should close the current modal first + expect(onClose).toHaveBeenCalledTimes(1) + // Then open pricing modal + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should call onClose and custom onUpgrade when provided', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onUpgrade = vi.fn() + + render( + , + ) + + const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeText) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onUpgrade).toHaveBeenCalledTimes(1) + // Custom onUpgrade replaces default setShowPricingModal + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + + it('should call onClose when clicking dismiss button', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + , + ) + + const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i) + await user.click(dismissBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + }) + + // Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing + describe('PlanComp upgrade button triggers pricing', () => { + it('should open pricing modal when clicking upgrade in sandbox plan', async () => { + const user = userEvent.setup() + setupProviderContext({ type: Plan.sandbox }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. Capacity Full Components Integration +// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal +// with real child components (UsageInfo, ProgressBar, UpgradeBtn) +// ═══════════════════════════════════════════════════════════════════════════ +describe('Capacity Full Components Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + // AppsFull renders with correct messaging and components + describe('AppsFull integration', () => { + it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 5 }, + total: { buildApps: 5 }, + }) + + render() + + // Should show "full" tip + expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument() + // Should show upgrade button + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + // Should show usage/total fraction "5/5" + expect(screen.getByText(/5\/5/)).toBeInTheDocument() + // Should have a progress bar rendered + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + }) + + it('should display upgrade tip and upgrade button for professional plan', () => { + setupProviderContext({ + type: Plan.professional, + usage: { buildApps: 48 }, + total: { buildApps: 50 }, + }) + + render() + + expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should display contact tip and contact button for team plan', () => { + setupProviderContext({ + type: Plan.team, + usage: { buildApps: 200 }, + total: { buildApps: 200 }, + }) + + render() + + // Team plan shows different tip + expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument() + // Team plan shows "Contact Us" instead of upgrade + expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + }) + + it('should render progress bar with correct color based on usage percentage', () => { + // 100% usage should show error color + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 5 }, + total: { buildApps: 5 }, + }) + + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + }) + + // VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn + describe('VectorSpaceFull integration', () => { + it('should display full tip, upgrade button, and vector space usage info', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 50 }, + total: { vectorSpace: 50 }, + }) + + render() + + // Should show full tip + expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument() + expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument() + // Should show upgrade button + expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument() + // Should show vector space usage info + expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument() + }) + }) + + // AnnotationFull renders with Usage component and UpgradeBtn + describe('AnnotationFull integration', () => { + it('should display annotation full tip, upgrade button, and usage info', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument() + expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument() + // UpgradeBtn rendered + expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument() + // Usage component should show annotation quota + expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument() + }) + }) + + // AnnotationFullModal shows modal with usage and upgrade button + describe('AnnotationFullModal integration', () => { + it('should render modal with annotation info and upgrade button when show is true', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument() + expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument() + }) + + it('should not render content when show is false', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument() + }) + }) + + // TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo + describe('TriggerEventsLimitModal integration', () => { + it('should display trigger limit title, usage info, and upgrade button', () => { + setupProviderContext({ type: Plan.professional }) + + render( + , + ) + + // Modal title and description + expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument() + // Embedded UsageInfo with trigger events data + expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument() + expect(screen.getByText('18000')).toBeInTheDocument() + expect(screen.getByText('20000')).toBeInTheDocument() + // Reset info + expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument() + // Upgrade and dismiss buttons + expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument() + }) + + it('should call onClose and onUpgrade when clicking upgrade', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onUpgrade = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render( + , + ) + + const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onUpgrade).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. Header Billing Button Integration +// Tests HeaderBillingBtn behavior for different plan states +// ═══════════════════════════════════════════════════════════════════════════ +describe('Header Billing Button Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should render UpgradeBtn (premium badge) for sandbox plan', () => { + setupProviderContext({ type: Plan.sandbox }) + + render() + + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should render "pro" badge for professional plan', () => { + setupProviderContext({ type: Plan.professional }) + + render() + + expect(screen.getByText('pro')).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument() + }) + + it('should render "team" badge for team plan', () => { + setupProviderContext({ type: Plan.team }) + + render() + + expect(screen.getByText('team')).toBeInTheDocument() + }) + + it('should return null when billing is disabled', () => { + setupProviderContext({ type: Plan.sandbox }, { enableBilling: false }) + + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('should return null when plan is not fetched yet', () => { + setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false }) + + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('should call onClick when clicking pro/team badge in non-display-only mode', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render() + + await user.click(screen.getByText('pro')) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not call onClick when isDisplayOnly is true', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render() + + await user.click(screen.getByText('pro')) + + expect(onClick).not.toHaveBeenCalled() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. PriorityLabel Integration +// Tests priority badge display for different plan types +// ═══════════════════════════════════════════════════════════════════════════ +describe('PriorityLabel Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should display "standard" priority for sandbox plan', () => { + setupProviderContext({ type: Plan.sandbox }) + + render() + + expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument() + }) + + it('should display "priority" for professional plan with icon', () => { + setupProviderContext({ type: Plan.professional }) + + const { container } = render() + + expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument() + // Professional plan should show the priority icon + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should display "top-priority" for team plan with icon', () => { + setupProviderContext({ type: Plan.team }) + + const { container } = render() + + expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should display "top-priority" for enterprise plan', () => { + setupProviderContext({ type: Plan.enterprise }) + + render() + + expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Usage Display Edge Cases +// Tests storage mode, threshold logic, and progress bar color integration +// ═══════════════════════════════════════════════════════════════════════════ +describe('Usage Display Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + // Vector space storage mode behavior + describe('VectorSpace storage mode in PlanComp', () => { + it('should show "< 50" for sandbox plan with low vector space usage', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 10 }, + total: { vectorSpace: 50 }, + }) + + render() + + // Storage mode: usage below threshold shows "< 50" + expect(screen.getByText(/ { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 10 }, + total: { vectorSpace: 50 }, + }) + + render() + + // Should have an indeterminate progress bar + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should show actual usage for pro plan above threshold', () => { + setupProviderContext({ + type: Plan.professional, + usage: { vectorSpace: 1024 }, + total: { vectorSpace: 5120 }, + }) + + render() + + // Pro plan above threshold shows actual value + expect(screen.getByText('1024')).toBeInTheDocument() + }) + }) + + // Progress bar color logic through real components + describe('Progress bar color reflects usage severity', () => { + it('should show normal color for low usage percentage', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 1 }, + total: { buildApps: 5 }, + }) + + render() + + // 20% usage - normal color + const progressBars = screen.getAllByTestId('billing-progress-bar') + // At least one should have the normal progress color + const hasNormalColor = progressBars.some(bar => + bar.classList.contains('bg-components-progress-bar-progress-solid'), + ) + expect(hasNormalColor).toBe(true) + }) + }) + + // Reset days calculation in PlanComp + describe('Reset days integration', () => { + it('should not show reset for sandbox trigger events (no reset_date)', () => { + setupProviderContext({ + type: Plan.sandbox, + total: { triggerEvents: 3000 }, + reset: { triggerEvents: null }, + }) + + render() + + // Find the trigger events section - should not have reset text + const triggerSection = screen.getByText(/usagePage\.triggerEvents/i) + const parent = triggerSection.closest('[class*="flex flex-col"]') + // No reset text should appear (sandbox doesn't show reset for triggerEvents) + expect(parent?.textContent).not.toContain('usagePage.resetsIn') + }) + + it('should show reset for professional trigger events with reset date', () => { + setupProviderContext({ + type: Plan.professional, + total: { triggerEvents: 20000 }, + reset: { triggerEvents: 14 }, + }) + + render() + + // Professional plan with finite triggerEvents should show reset + const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i) + expect(resetTexts.length).toBeGreaterThan(0) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. Cross-Component Upgrade Flow (End-to-End) +// Tests the complete chain: capacity alert → upgrade button → pricing +// ═══════════════════════════════════════════════════════════════════════════ +describe('Cross-Component Upgrade Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should trigger pricing from AppsFull upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 5 }, + total: { buildApps: 5 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from VectorSpaceFull upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 50 }, + total: { vectorSpace: 50 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from AnnotationFull upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render( + , + ) + + // TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal + // PlanUpgradeModal's upgrade button calls onClose then onUpgrade + const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from AnnotationFullModal upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx new file mode 100644 index 0000000000..e01d9250fd --- /dev/null +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -0,0 +1,296 @@ +/** + * Integration test: Cloud Plan Payment Flow + * + * Tests the payment flow for cloud plan items: + * CloudPlanItem → Button click → permission check → fetch URL → redirect + * + * Covers plan comparison, downgrade prevention, monthly/yearly pricing, + * and workspace manager permission enforcement. + */ +import type { BasicPlan } from '@/app/components/billing/type' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { ALL_PLANS } from '@/app/components/billing/config' +import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher' +import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockAppCtx: Record = {} +const mockFetchSubscriptionUrls = vi.fn() +const mockInvoices = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockToastNotify = vi.fn() + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + billing: { + invoices: () => mockInvoices(), + }, + }, +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (args: unknown) => mockToastNotify(args) }, +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const setupAppContext = (overrides: Record = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + ...overrides, + } +} + +type RenderCloudPlanItemOptions = { + currentPlan?: BasicPlan + plan?: BasicPlan + planRange?: PlanRange + canPay?: boolean +} + +const renderCloudPlanItem = ({ + currentPlan = Plan.sandbox, + plan = Plan.professional, + planRange = PlanRange.monthly, + canPay = true, +}: RenderCloudPlanItemOptions = {}) => { + return render( + , + ) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Cloud Plan Payment Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupAppContext() + mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' }) + mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' }) + }) + + // ─── 1. Plan Display ──────────────────────────────────────────────────── + describe('Plan display', () => { + it('should render plan name and description', () => { + renderCloudPlanItem({ plan: Plan.professional }) + + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument() + }) + + it('should show "Free" price for sandbox plan', () => { + renderCloudPlanItem({ plan: Plan.sandbox }) + + expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() + }) + + it('should show monthly price for paid plans', () => { + renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly }) + + expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument() + }) + + it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => { + renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly }) + + const yearlyPrice = ALL_PLANS.professional.price * 10 + const originalPrice = ALL_PLANS.professional.price * 12 + + expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument() + expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument() + }) + + it('should show "most popular" badge for professional plan', () => { + renderCloudPlanItem({ plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument() + }) + + it('should not show "most popular" badge for sandbox or team plans', () => { + const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox }) + expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument() + unmount() + + renderCloudPlanItem({ plan: Plan.team }) + expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument() + }) + }) + + // ─── 2. Button Text Logic ─────────────────────────────────────────────── + describe('Button text logic', () => { + it('should show "Current Plan" when plan matches current plan', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + + it('should show "Start for Free" for sandbox plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox }) + + expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument() + }) + + it('should show "Start Building" for professional plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument() + }) + + it('should show "Get Started" for team plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team }) + + expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument() + }) + }) + + // ─── 3. Downgrade Prevention ──────────────────────────────────────────── + describe('Downgrade prevention', () => { + it('should disable sandbox button when user is on professional plan (downgrade)', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox }) + + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + + it('should disable sandbox and professional buttons when user is on team plan', () => { + const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox }) + expect(screen.getByRole('button')).toBeDisabled() + unmount() + + renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional }) + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not disable current paid plan button (for invoice management)', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + + it('should enable higher-tier plan buttons for upgrade', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team }) + + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + }) + + // ─── 4. Payment URL Flow ──────────────────────────────────────────────── + describe('Payment URL flow', () => { + it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => { + const user = userEvent.setup() + // Simulate clicking on a professional plan button (user is on sandbox) + renderCloudPlanItem({ + currentPlan: Plan.sandbox, + plan: Plan.professional, + planRange: PlanRange.monthly, + }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month') + }) + }) + + it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ + currentPlan: Plan.sandbox, + plan: Plan.team, + planRange: PlanRange.yearly, + }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year') + }) + }) + + it('should open invoice management for current paid plan', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalled() + }) + // Should NOT call fetchSubscriptionUrls (invoice, not subscription) + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + + it('should not do anything when clicking on sandbox free plan button', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox }) + + const button = screen.getByRole('button') + await user.click(button) + + // Wait a tick and verify no actions were taken + await waitFor(() => { + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + expect(mockOpenAsyncWindow).not.toHaveBeenCalled() + }) + }) + }) + + // ─── 5. Permission Check ──────────────────────────────────────────────── + describe('Permission check', () => { + it('should show error toast when non-manager clicks upgrade button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + // Should not proceed with payment + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx new file mode 100644 index 0000000000..8c35cd9a8c --- /dev/null +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -0,0 +1,318 @@ +/** + * Integration test: Education Verification Flow + * + * Tests the education plan verification flow in PlanComp: + * PlanComp → handleVerify → useEducationVerify → router.push → education-apply + * PlanComp → handleVerify → error → show VerifyStateModal + * + * Also covers education button visibility based on context flags. + */ +import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { defaultPlan } from '@/app/components/billing/config' +import PlanComp from '@/app/components/billing/plan' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockRouterPush = vi.fn() +const mockMutateAsync = vi.fn() + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), + useModalContextSelector: (selector: (s: Record) => unknown) => + selector({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})) + +vi.mock('@/service/use-billing', () => ({ + useBillingUrl: () => ({ + data: 'https://billing.example.com', + isFetching: false, + refetch: vi.fn(), + }), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── External component mocks ─────────────────────────────────────────────── +vi.mock('@/app/education-apply/verify-state-modal', () => ({ + default: ({ isShow, title, content, email, showLink }: { + isShow: boolean + title?: string + content?: string + email?: string + showLink?: boolean + }) => + isShow + ? ( +
+ {title && {title}} + {content && {content}} + {email && {email}} + {showLink && link} +
+ ) + : null, +})) + +// ─── Test data factories ──────────────────────────────────────────────────── +type PlanOverrides = { + type?: string + usage?: Partial + total?: Partial + reset?: Partial +} + +const createPlanData = (overrides: PlanOverrides = {}) => ({ + ...defaultPlan, + ...overrides, + type: overrides.type ?? defaultPlan.type, + usage: { ...defaultPlan.usage, ...overrides.usage }, + total: { ...defaultPlan.total, ...overrides.total }, + reset: { ...defaultPlan.reset, ...overrides.reset }, +}) + +const setupContexts = ( + planOverrides: PlanOverrides = {}, + providerOverrides: Record = {}, + appOverrides: Record = {}, +) => { + mockProviderCtx = { + plan: createPlanData(planOverrides), + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + ...providerOverrides, + } + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'student@university.edu' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...appOverrides, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Education Verification Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupContexts() + }) + + // ─── 1. Education Button Visibility ───────────────────────────────────── + describe('Education button visibility', () => { + it('should not show verify button when enableEducationPlan is false', () => { + setupContexts({}, { enableEducationPlan: false }) + + render() + + expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument() + }) + + it('should show verify button when enableEducationPlan is true and not yet verified', () => { + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) + + it('should not show verify button when already verified and not about to expire', () => { + setupContexts({}, { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: false, + }) + + render() + + expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument() + }) + + it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => { + setupContexts({}, { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: true, + }) + + render() + + // Shown because isAboutToExpire = allowRefreshEducationVerify = true + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) + }) + + // ─── 2. Successful Verification Flow ──────────────────────────────────── + describe('Successful verification flow', () => { + it('should navigate to education-apply with token on successful verification', async () => { + mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' }) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + const verifyButton = screen.getByText(/toVerified/i) + await user.click(verifyButton) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123') + }) + }) + + it('should remove education verifying flag from localStorage on success', async () => { + mockMutateAsync.mockResolvedValue({ token: 'token-xyz' }) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying') + }) + }) + }) + + // ─── 3. Failed Verification Flow ──────────────────────────────────────── + describe('Failed verification flow', () => { + it('should show VerifyStateModal with rejection info on error', async () => { + mockMutateAsync.mockRejectedValue(new Error('Verification failed')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + // Modal should not be visible initially + expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument() + + const verifyButton = screen.getByText(/toVerified/i) + await user.click(verifyButton) + + // Modal should appear after verification failure + await waitFor(() => { + expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() + }) + + // Modal should display rejection title and content + expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i) + expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i) + }) + + it('should show email and link in VerifyStateModal', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu') + expect(screen.getByTestId('modal-show-link')).toBeInTheDocument() + }) + }) + + it('should not redirect on verification failure', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() + }) + + // Should NOT navigate + expect(mockRouterPush).not.toHaveBeenCalled() + }) + }) + + // ─── 4. Education + Upgrade Coexistence ───────────────────────────────── + describe('Education and upgrade button coexistence', () => { + it('should show both education verify and upgrade buttons for sandbox user', () => { + setupContexts( + { type: Plan.sandbox }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should not show upgrade button for enterprise plan', () => { + setupContexts( + { type: Plan.enterprise }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument() + }) + + it('should show team plan with plain upgrade button and education button', () => { + setupContexts( + { type: Plan.team }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/billing/partner-stack-flow.test.tsx b/web/__tests__/billing/partner-stack-flow.test.tsx new file mode 100644 index 0000000000..4f265478cd --- /dev/null +++ b/web/__tests__/billing/partner-stack-flow.test.tsx @@ -0,0 +1,326 @@ +/** + * Integration test: Partner Stack Flow + * + * Tests the PartnerStack integration: + * PartnerStack component → usePSInfo hook → cookie management → bind API call + * + * Covers URL param reading, cookie persistence, API bind on mount, + * cookie cleanup after successful bind, and error handling for 400 status. + */ +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react' +import Cookies from 'js-cookie' +import * as React from 'react' +import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info' +import { PARTNER_STACK_CONFIG } from '@/config' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockSearchParams = new URLSearchParams() +const mockMutateAsync = vi.fn() + +// ─── Module mocks ──────────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useSearchParams: () => mockSearchParams, + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/', +})) + +vi.mock('@/service/use-billing', () => ({ + useBindPartnerStackInfo: () => ({ + mutateAsync: mockMutateAsync, + }), + useBillingUrl: () => ({ + data: '', + isFetching: false, + refetch: vi.fn(), + }), +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + IS_CLOUD_EDITION: true, + PARTNER_STACK_CONFIG: { + cookieName: 'partner_stack_info', + saveCookieDays: 90, + }, + } +}) + +// ─── Cookie helpers ────────────────────────────────────────────────────────── +const getCookieData = () => { + const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName) + if (!raw) + return null + try { + return JSON.parse(raw) + } + catch { + return null + } +} + +const setCookieData = (data: Record) => { + Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data)) +} + +const clearCookie = () => { + Cookies.remove(PARTNER_STACK_CONFIG.cookieName) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Partner Stack Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + clearCookie() + mockSearchParams = new URLSearchParams() + mockMutateAsync.mockResolvedValue({}) + }) + + // ─── 1. URL Param Reading ─────────────────────────────────────────────── + describe('URL param reading', () => { + it('should read ps_partner_key and ps_xid from URL search params', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-123', + ps_xid: 'click-456', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('partner-123') + expect(result.current.psClickId).toBe('click-456') + }) + + it('should fall back to cookie when URL params are not present', () => { + setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('cookie-partner') + expect(result.current.psClickId).toBe('cookie-click') + }) + + it('should prefer URL params over cookie values', () => { + setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'url-partner', + ps_xid: 'url-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('url-partner') + expect(result.current.psClickId).toBe('url-click') + }) + + it('should return null for both values when no params and no cookie', () => { + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBeUndefined() + expect(result.current.psClickId).toBeUndefined() + }) + }) + + // ─── 2. Cookie Persistence (saveOrUpdate) ─────────────────────────────── + describe('Cookie persistence via saveOrUpdate', () => { + it('should save PS info to cookie when URL params provide new values', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'new-partner', + ps_xid: 'new-click', + }) + + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + const cookieData = getCookieData() + expect(cookieData).toEqual({ + partnerKey: 'new-partner', + clickId: 'new-click', + }) + }) + + it('should not update cookie when values have not changed', () => { + setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'same-partner', + ps_xid: 'same-click', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + // Should not call set because values haven't changed + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + + it('should not save to cookie when partner key is missing', () => { + mockSearchParams = new URLSearchParams({ + ps_xid: 'click-only', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + + it('should not save to cookie when click ID is missing', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-only', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + }) + + // ─── 3. Bind API Flow ────────────────────────────────────────────────── + describe('Bind API flow', () => { + it('should call mutateAsync with partnerKey and clickId on bind', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'bind-partner', + ps_xid: 'bind-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + partnerKey: 'bind-partner', + clickId: 'bind-click', + }) + }) + + it('should remove cookie after successful bind', async () => { + setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'rm-partner', + ps_xid: 'rm-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should be removed after successful bind + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should remove cookie on 400 error (already bound)', async () => { + mockMutateAsync.mockRejectedValue({ status: 400 }) + setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'err-partner', + ps_xid: 'err-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should be removed even on 400 + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should not remove cookie on non-400 errors', async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }) + setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'keep-partner', + ps_xid: 'keep-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should still exist for non-400 errors + const cookieData = getCookieData() + expect(cookieData).toBeTruthy() + }) + + it('should not call bind when partner key is missing', async () => { + mockSearchParams = new URLSearchParams({ + ps_xid: 'click-only', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + expect(mockMutateAsync).not.toHaveBeenCalled() + }) + + it('should not call bind a second time (idempotency)', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-once', + ps_xid: 'click-once', + }) + + const { result } = renderHook(() => usePSInfo()) + + // First bind + await act(async () => { + await result.current.bind() + }) + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + + // Second bind should be skipped (hasBind = true) + await act(async () => { + await result.current.bind() + }) + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + }) + }) + + // ─── 4. PartnerStack Component Mount ──────────────────────────────────── + describe('PartnerStack component mount behavior', () => { + it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'mount-partner', + ps_xid: 'mount-click', + }) + + // Use lazy import so the mocks are applied + const { default: PartnerStack } = await import('@/app/components/billing/partner-stack') + + render() + + // The component calls saveOrUpdate and bind in useEffect + await waitFor(() => { + // Bind should have been called + expect(mockMutateAsync).toHaveBeenCalledWith({ + partnerKey: 'mount-partner', + clickId: 'mount-click', + }) + }) + + // Cookie should have been saved (saveOrUpdate was called before bind) + // After bind succeeds, cookie is removed + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should render nothing (return null)', async () => { + const { default: PartnerStack } = await import('@/app/components/billing/partner-stack') + + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + }) +}) diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx new file mode 100644 index 0000000000..6b8fb57f83 --- /dev/null +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -0,0 +1,327 @@ +/** + * Integration test: Pricing Modal Flow + * + * Tests the full Pricing modal lifecycle: + * Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted) + * → CloudPlanItem / SelfHostedPlanItem → Footer + * + * Validates cross-component state propagation when the user switches between + * cloud / self-hosted categories and monthly / yearly plan ranges. + */ +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { ALL_PLANS } from '@/app/components/billing/config' +import Pricing from '@/app/components/billing/pricing' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', + useGetPricingPageLanguage: () => 'en', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + billing: { + invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }), + }, + }, +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +// ─── External component mocks (lightweight) ───────────────────────────────── +vi.mock('@/app/components/base/icons/src/public/billing', () => ({ + Azure: () => , + GoogleCloud: () => , + AwsMarketplaceLight: () => , + AwsMarketplaceDark: () => , +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), + useTheme: () => ({ theme: 'light' }), +})) + +// Self-hosted List uses t() with returnObjects which returns string in mock; +// mock it to avoid deep i18n dependency (unit tests cover this component) +vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( +
Features
+ ), +})) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const defaultPlanData = { + type: Plan.sandbox, + usage: { + buildApps: 1, + teamMembers: 1, + documentsUploadQuota: 0, + vectorSpace: 10, + annotatedResponse: 1, + triggerEvents: 0, + apiRateLimit: 0, + }, + total: { + buildApps: 5, + teamMembers: 1, + documentsUploadQuota: 50, + vectorSpace: 50, + annotatedResponse: 10, + triggerEvents: 3000, + apiRateLimit: 5000, + }, +} + +const setupContexts = (planOverrides: Record = {}, appOverrides: Record = {}) => { + mockProviderCtx = { + plan: { ...defaultPlanData, ...planOverrides }, + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + } + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'test@example.com' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...appOverrides, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Pricing Modal Flow', () => { + const onCancel = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupContexts() + }) + + // ─── 1. Initial Rendering ──────────────────────────────────────────────── + describe('Initial rendering', () => { + it('should render header with close button and footer with pricing link', () => { + render() + + // Header close button exists (multiple plan buttons also exist) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + // Footer pricing link + expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument() + }) + + it('should default to cloud category with three cloud plans', () => { + render() + + // Three cloud plans: sandbox, professional, team + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument() + }) + + it('should show plan range switcher (annual billing toggle) by default for cloud', () => { + render() + + expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument() + }) + + it('should show tax tip in footer for cloud category', () => { + render() + + // Use exact match to avoid matching taxTipSecond + expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument() + }) + }) + + // ─── 2. Category Switching ─────────────────────────────────────────────── + describe('Category switching', () => { + it('should switch to self-hosted plans when clicking self-hosted tab', async () => { + const user = userEvent.setup() + render() + + // Click the self-hosted tab + const selfTab = screen.getByText(/plansCommon\.self/i) + await user.click(selfTab) + + // Self-hosted plans should appear + expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() + + // Cloud plans should disappear + expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument() + }) + + it('should hide plan range switcher for self-hosted category', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + // Annual billing toggle should not be visible + expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument() + }) + + it('should hide tax tip in footer for self-hosted category', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument() + }) + + it('should switch back to cloud plans when clicking cloud tab', async () => { + const user = userEvent.setup() + render() + + // Switch to self-hosted + await user.click(screen.getByText(/plansCommon\.self/i)) + expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument() + + // Switch back to cloud + await user.click(screen.getByText(/plansCommon\.cloud/i)) + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument() + }) + }) + + // ─── 3. Plan Range Switching (Monthly ↔ Yearly) ────────────────────────── + describe('Plan range switching', () => { + it('should show monthly prices by default', () => { + render() + + // Professional monthly price: $59 + const proPriceStr = `$${ALL_PLANS.professional.price}` + expect(screen.getByText(proPriceStr)).toBeInTheDocument() + + // Team monthly price: $159 + const teamPriceStr = `$${ALL_PLANS.team.price}` + expect(screen.getByText(teamPriceStr)).toBeInTheDocument() + }) + + it('should show "Free" for sandbox plan regardless of range', () => { + render() + + expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() + }) + + it('should show "most popular" badge only for professional plan', () => { + render() + + expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument() + }) + }) + + // ─── 4. Cloud Plan Button States ───────────────────────────────────────── + describe('Cloud plan button states', () => { + it('should show "Current Plan" for the current plan (sandbox)', () => { + setupContexts({ type: Plan.sandbox }) + render() + + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + + it('should show specific button text for non-current plans', () => { + setupContexts({ type: Plan.sandbox }) + render() + + // Professional button text + expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument() + // Team button text + expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument() + }) + + it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => { + setupContexts({ type: Plan.enterprise }) + render() + + // Enterprise is normalized to team for display, so team is "Current Plan" + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + }) + + // ─── 5. Self-Hosted Plan Details ───────────────────────────────────────── + describe('Self-hosted plan details', () => { + it('should show cloud provider icons only for premium plan', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + // Premium plan should show Azure and Google Cloud icons + expect(screen.getByTestId('icon-azure')).toBeInTheDocument() + expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument() + }) + + it('should show "coming soon" text for premium plan cloud providers', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument() + }) + }) + + // ─── 6. Close Handling ─────────────────────────────────────────────────── + describe('Close handling', () => { + it('should call onCancel when pressing ESC key', () => { + render() + + // ahooks useKeyPress listens on document for keydown events + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + bubbles: true, + })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // ─── 7. Pricing URL ───────────────────────────────────────────────────── + describe('Pricing page URL', () => { + it('should render pricing link with correct URL', () => { + render() + + const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i) + expect(link.closest('a')).toHaveAttribute( + 'href', + 'https://dify.ai/en/pricing#plans-and-features', + ) + }) + }) +}) diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx new file mode 100644 index 0000000000..810d36da8a --- /dev/null +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -0,0 +1,225 @@ +/** + * Integration test: Self-Hosted Plan Flow + * + * Tests the self-hosted plan items: + * SelfHostedPlanItem → Button click → permission check → redirect to external URL + * + * Covers community/premium/enterprise plan rendering, external URL navigation, + * and workspace manager permission enforcement. + */ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config' +import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item' +import { SelfHostedPlan } from '@/app/components/billing/type' + +let mockAppCtx: Record = {} +const mockToastNotify = vi.fn() + +const originalLocation = window.location +let assignedHref = '' + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), + useTheme: () => ({ theme: 'light' }), +})) + +vi.mock('@/app/components/base/icons/src/public/billing', () => ({ + Azure: () => , + GoogleCloud: () => , + AwsMarketplaceLight: () => , + AwsMarketplaceDark: () => , +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (args: unknown) => mockToastNotify(args) }, +})) + +vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( +
Features
+ ), +})) + +const setupAppContext = (overrides: Record = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + ...overrides, + } +} + +describe('Self-Hosted Plan Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupAppContext() + + // Mock window.location with minimal getter/setter (Location props are non-enumerable) + assignedHref = '' + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { return assignedHref }, + set href(value: string) { assignedHref = value }, + }, + }) + }) + + afterEach(() => { + // Restore original location + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) + }) + + // ─── 1. Plan Rendering ────────────────────────────────────────────────── + describe('Plan rendering', () => { + it('should render community plan with name and description', () => { + render() + + expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument() + }) + + it('should render premium plan with cloud provider icons', () => { + render() + + expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() + expect(screen.getByTestId('icon-azure')).toBeInTheDocument() + expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument() + }) + + it('should render enterprise plan without cloud provider icons', () => { + render() + + expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() + expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument() + }) + + it('should not show price tip for community (free) plan', () => { + render() + + expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument() + }) + + it('should show price tip for premium plan', () => { + render() + + expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument() + }) + + it('should render features list for each plan', () => { + const { unmount: unmount1 } = render() + expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument() + unmount1() + + const { unmount: unmount2 } = render() + expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument() + unmount2() + + render() + expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument() + }) + + it('should show AWS marketplace icon for premium plan button', () => { + render() + + expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument() + }) + }) + + // ─── 2. Navigation Flow ───────────────────────────────────────────────── + describe('Navigation flow', () => { + it('should redirect to GitHub when clicking community plan button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getStartedWithCommunityUrl) + }) + + it('should redirect to AWS Marketplace when clicking premium plan button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getWithPremiumUrl) + }) + + it('should redirect to Typeform when clicking enterprise plan button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(contactSalesUrl) + }) + }) + + // ─── 3. Permission Check ──────────────────────────────────────────────── + describe('Permission check', () => { + it('should show error toast when non-manager clicks community button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + // Should NOT redirect + expect(assignedHref).toBe('') + }) + + it('should show error toast when non-manager clicks premium button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + expect(assignedHref).toBe('') + }) + + it('should show error toast when non-manager clicks enterprise button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + expect(assignedHref).toBe('') + }) + }) +}) diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx new file mode 100644 index 0000000000..e3a59edde6 --- /dev/null +++ b/web/__tests__/datasets/create-dataset-flow.test.tsx @@ -0,0 +1,301 @@ +/** + * Integration Test: Create Dataset Flow + * + * Tests cross-module data flow: step-one data → step-two hooks → creation params → API call + * Validates data contracts between steps. + */ + +import type { CustomFile } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +const mockCreateFirstDocument = vi.fn() +const mockCreateDocument = vi.fn() +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }), + useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }), + getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({ + workspace_id: 'ws-1', + pages: pages.map(p => p.page_id), + notion_credential_id: credentialId, + }), + getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({ + urls: opts.websitePages.map(p => p.url), + only_main_content: true, + provider: opts.websiteCrawlProvider, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => vi.fn(), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +// Import hooks after mocks +const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP } + = await import('@/app/components/datasets/create/step-two/hooks') +const { useDocumentCreation, IndexingType } + = await import('@/app/components/datasets/create/step-two/hooks') + +const createMockFile = (overrides?: Partial): CustomFile => ({ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 1024, + extension: '.txt', + mime_type: 'text/plain', + created_at: 0, + created_by: '', + ...overrides, +} as CustomFile) + +describe('Create Dataset Flow - Cross-Step Data Contract', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step-One → Step-Two: Segmentation Defaults', () => { + it('should initialise with correct default segmentation values', () => { + const { result } = renderHook(() => useSegmentationState()) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.segmentationType).toBe(ProcessMode.general) + }) + + it('should produce valid process rule for general chunking', () => { + const { result } = renderHook(() => useSegmentationState()) + const processRule = result.current.getProcessRule(ChunkingMode.text) + + // mode should be segmentationType = ProcessMode.general = 'custom' + expect(processRule.mode).toBe('custom') + expect(processRule.rules.segmentation).toEqual({ + separator: '\n\n', // unescaped from \\n\\n + max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH, + chunk_overlap: DEFAULT_OVERLAP, + }) + // rules is empty initially since no default config loaded + expect(processRule.rules.pre_processing_rules).toEqual([]) + }) + + it('should produce valid process rule for parent-child chunking', () => { + const { result } = renderHook(() => useSegmentationState()) + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('paragraph') + expect(processRule.rules.segmentation).toEqual({ + separator: '\n\n', + max_tokens: 1024, + }) + expect(processRule.rules.subchunk_segmentation).toEqual({ + separator: '\n', + max_tokens: 512, + }) + }) + }) + + describe('Step-Two → Creation API: Params Building', () => { + it('should build valid creation params for file upload workflow', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const processRule = segResult.current.getProcessRule(ChunkingMode.text) + const retrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } + + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'English', + processRule, + retrievalConfig, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + // File IDs come from file.id (not file.file.id) + expect(params!.data_source.type).toBe(DataSourceType.FILE) + expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1') + + expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED) + expect(params!.doc_form).toBe(ChunkingMode.text) + expect(params!.doc_language).toBe('English') + expect(params!.embedding_model).toBe('text-embedding-ada-002') + expect(params!.embedding_model_provider).toBe('openai') + expect(params!.process_rule.mode).toBe('custom') + }) + + it('should validate params: overlap must not exceed maxChunkLength', () => { + const { result } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // validateParams returns false (invalid) when overlap > maxChunkLength for general mode + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 100, + limitMaxChunkLength: 4000, + overlap: 200, // overlap > maxChunkLength + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + }) + expect(isValid).toBe(false) + }) + + it('should validate params: maxChunkLength must not exceed limit', () => { + const { result } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 5000, + limitMaxChunkLength: 4000, // limit < maxChunkLength + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + }) + expect(isValid).toBe(false) + }) + }) + + describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => { + it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // Change segmentation settings + act(() => { + segResult.current.setMaxChunkLength(2048) + segResult.current.setOverlap(100) + }) + + const processRule = segResult.current.getProcessRule(ChunkingMode.text) + expect(processRule.rules.segmentation.max_tokens).toBe(2048) + expect(processRule.rules.segmentation.chunk_overlap).toBe(100) + + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'Chinese', + processRule, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048) + expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100) + expect(params!.doc_language).toBe('Chinese') + }) + + it('should support parent-child mode through the full pipeline', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild) + const params = creationResult.current.buildCreationParams( + ChunkingMode.parentChild, + 'English', + processRule, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + expect(params!.doc_form).toBe(ChunkingMode.parentChild) + expect(params!.process_rule.mode).toBe('hierarchical') + expect(params!.process_rule.rules.parent_mode).toBe('paragraph') + expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined() + }) + }) +}) diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx new file mode 100644 index 0000000000..607cd8c2d5 --- /dev/null +++ b/web/__tests__/datasets/dataset-settings-flow.test.tsx @@ -0,0 +1,451 @@ +/** + * Integration Test: Dataset Settings Flow + * + * Tests cross-module data contracts in the dataset settings form: + * useFormState hook ↔ index method config ↔ retrieval config ↔ permission state. + * + * The unit-level use-form-state.spec.ts validates the hook in isolation. + * This integration test verifies that changing one configuration dimension + * correctly cascades to dependent parts (index method → retrieval config, + * permission → member list visibility, embedding model → embedding available state). + */ + +import type { DataSet } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, renderHook, waitFor } from '@testing-library/react' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +// --- Mocks --- + +const mockMutateDatasets = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({}) + +vi.mock('@/context/app-context', () => ({ + useSelector: () => false, +})) + +vi.mock('@/service/datasets', () => ({ + updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-common', () => ({ + useMembers: () => ({ + data: { + accounts: [ + { id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + ], + }, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: () => true, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +// --- Dataset factory --- + +const createMockDataset = (overrides?: Partial): DataSet => ({ + id: 'ds-settings-1', + name: 'Settings Test Dataset', + description: 'Integration test dataset', + permission: DatasetPermission.onlyMe, + icon_info: { + icon_type: 'emoji', + icon: '📙', + icon_background: '#FFF4ED', + icon_url: '', + }, + indexing_technique: 'high_quality', + indexing_status: 'completed', + data_source_type: DataSourceType.FILE, + doc_form: ChunkingMode.text, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + app_count: 2, + document_count: 10, + total_document_count: 10, + word_count: 5000, + provider: 'vendor', + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + built_in_field_enabled: false, + keyword_number: 10, + created_by: 'user-1', + updated_by: 'user-1', + updated_at: Date.now(), + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...overrides, +} as DataSet) + +let mockDataset: DataSet = createMockDataset() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown, + ) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }), +})) + +// Import after mocks are registered +const { useFormState } = await import( + '@/app/components/datasets/settings/form/hooks/use-form-state', +) + +describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateDatasetSetting.mockResolvedValue({}) + mockDataset = createMockDataset() + }) + + describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => { + it('should initialise all form dimensions from a QUALIFIED dataset', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.name).toBe('Settings Test Dataset') + expect(result.current.description).toBe('Integration test dataset') + expect(result.current.indexMethod).toBe('high_quality') + expect(result.current.embeddingModel).toEqual({ + provider: 'openai', + model: 'text-embedding-ada-002', + }) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic) + }) + + it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => { + mockDataset = createMockDataset({ + indexing_technique: IndexingType.ECONOMICAL, + embedding_model: '', + embedding_model_provider: '', + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + }) + + const { result } = renderHook(() => useFormState()) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + expect(result.current.embeddingModel).toEqual({ provider: '', model: '' }) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) + }) + }) + + describe('Index Method Change → Retrieval Config Sync', () => { + it('should allow switching index method from QUALIFIED to ECONOMICAL', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.indexMethod).toBe('high_quality') + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + }) + + it('should allow updating retrieval config after index method switch', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + + act(() => { + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + }) + }) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) + expect(result.current.retrievalConfig.reranking_enable).toBe(false) + }) + + it('should preserve retrieval config when switching back to QUALIFIED', () => { + const { result } = renderHook(() => useFormState()) + + const originalConfig = { ...result.current.retrievalConfig } + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + act(() => { + result.current.setIndexMethod(IndexingType.QUALIFIED) + }) + + expect(result.current.indexMethod).toBe('high_quality') + expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method) + }) + }) + + describe('Permission Change → Member List Visibility Logic', () => { + it('should start with onlyMe permission and empty member selection', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.permission).toBe(DatasetPermission.onlyMe) + expect(result.current.selectedMemberIDs).toEqual([]) + }) + + it('should enable member selection when switching to partialMembers', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + }) + + expect(result.current.permission).toBe(DatasetPermission.partialMembers) + expect(result.current.memberList).toHaveLength(3) + expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3']) + }) + + it('should persist member selection through permission toggle', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + result.current.setSelectedMemberIDs(['user-1', 'user-3']) + }) + + act(() => { + result.current.setPermission(DatasetPermission.allTeamMembers) + }) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + }) + + expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3']) + }) + + it('should include partial_member_list in save payload only for partialMembers', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + result.current.setSelectedMemberIDs(['user-2']) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + permission: DatasetPermission.partialMembers, + partial_member_list: [ + expect.objectContaining({ user_id: 'user-2', role: 'admin' }), + ], + }), + }) + }) + + it('should not include partial_member_list for allTeamMembers permission', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.allTeamMembers) + }) + + await act(async () => { + await result.current.handleSave() + }) + + const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record + expect(savedBody).not.toHaveProperty('partial_member_list') + }) + }) + + describe('Form Submission Validation → All Fields Together', () => { + it('should reject empty name on save', async () => { + const Toast = await import('@/app/components/base/toast') + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setName('') + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(Toast.default.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) + + it('should include all configuration dimensions in a successful save', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setName('Updated Name') + result.current.setDescription('Updated Description') + result.current.setIndexMethod(IndexingType.ECONOMICAL) + result.current.setKeywordNumber(15) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + name: 'Updated Name', + description: 'Updated Description', + indexing_technique: 'economy', + keyword_number: 15, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + }), + }) + }) + + it('should call mutateDatasets and invalidDatasetList after successful save', async () => { + const { result } = renderHook(() => useFormState()) + + await act(async () => { + await result.current.handleSave() + }) + + await waitFor(() => { + expect(mockMutateDatasets).toHaveBeenCalled() + expect(mockInvalidDatasetList).toHaveBeenCalled() + }) + }) + }) + + describe('Embedding Model Change → Retrieval Config Cascade', () => { + it('should update embedding model independently of retrieval config', () => { + const { result } = renderHook(() => useFormState()) + + const originalRetrievalConfig = { ...result.current.retrievalConfig } + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' }) + }) + + expect(result.current.embeddingModel).toEqual({ + provider: 'cohere', + model: 'embed-english-v3.0', + }) + expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method) + }) + + it('should propagate embedding model into weighted retrieval config on save', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.hybrid, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.6, + embedding_provider_name: '', + embedding_model_name: '', + }, + keyword_setting: { keyword_weight: 0.4 }, + }, + }) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + embedding_model: 'embed-v3', + embedding_model_provider: 'cohere', + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'cohere', + embedding_model_name: 'embed-v3', + }), + }), + }), + }), + }) + }) + + it('should handle switching from semantic to hybrid search with embedding model', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v3.0', + }, + }) + }) + + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid) + expect(result.current.retrievalConfig.reranking_enable).toBe(true) + expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002') + }) + }) +}) diff --git a/web/__tests__/datasets/document-management.test.tsx b/web/__tests__/datasets/document-management.test.tsx new file mode 100644 index 0000000000..3b901ccee2 --- /dev/null +++ b/web/__tests__/datasets/document-management.test.tsx @@ -0,0 +1,335 @@ +/** + * Integration Test: Document Management Flow + * + * Tests cross-module interactions: query state (URL-based) → document list sorting → + * document selection → status filter utilities. + * Validates the data contract between documents page hooks and list component hooks. + */ + +import type { SimpleDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(''), + useRouter: () => ({ push: mockPush }), + usePathname: () => '/datasets/ds-1/documents', +})) + +const { sanitizeStatusValue, normalizeStatusForQuery } = await import( + '@/app/components/datasets/documents/status-filter', +) + +const { useDocumentSort } = await import( + '@/app/components/datasets/documents/components/document-list/hooks/use-document-sort', +) +const { useDocumentSelection } = await import( + '@/app/components/datasets/documents/components/document-list/hooks/use-document-selection', +) +const { default: useDocumentListQueryState } = await import( + '@/app/components/datasets/documents/hooks/use-document-list-query-state', +) + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +const createDoc = (overrides?: Partial): LocalDoc => ({ + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'test-doc.txt', + word_count: 500, + hit_count: 10, + created_at: Date.now() / 1000, + data_source_type: DataSourceType.FILE, + display_status: 'available', + indexing_status: 'completed', + enabled: true, + archived: false, + doc_type: null, + doc_metadata: null, + position: 1, + dataset_process_rule_id: 'rule-1', + ...overrides, +} as LocalDoc) + +describe('Document Management Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Status Filter Utilities', () => { + it('should sanitize valid status values', () => { + expect(sanitizeStatusValue('all')).toBe('all') + expect(sanitizeStatusValue('available')).toBe('available') + expect(sanitizeStatusValue('error')).toBe('error') + }) + + it('should fallback to "all" for invalid values', () => { + expect(sanitizeStatusValue(null)).toBe('all') + expect(sanitizeStatusValue(undefined)).toBe('all') + expect(sanitizeStatusValue('')).toBe('all') + expect(sanitizeStatusValue('nonexistent')).toBe('all') + }) + + it('should handle URL aliases', () => { + // 'active' is aliased to 'available' + expect(sanitizeStatusValue('active')).toBe('available') + }) + + it('should normalize status for API query', () => { + expect(normalizeStatusForQuery('all')).toBe('all') + // 'enabled' normalized to 'available' for query + expect(normalizeStatusForQuery('enabled')).toBe('available') + }) + }) + + describe('URL-based Query State', () => { + it('should parse default query from empty URL params', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query).toEqual({ + page: 1, + limit: 10, + keyword: '', + status: 'all', + sort: '-created_at', + }) + }) + + it('should update query and push to router', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'test', page: 2 }) + }) + + expect(mockPush).toHaveBeenCalled() + // The push call should contain the updated query params + const pushUrl = mockPush.mock.calls[0][0] as string + expect(pushUrl).toContain('keyword=test') + expect(pushUrl).toContain('page=2') + }) + + it('should reset query to defaults', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalled() + // Default query omits default values from URL + const pushUrl = mockPush.mock.calls[0][0] as string + expect(pushUrl).toBe('/datasets/ds-1/documents') + }) + }) + + describe('Document Sort Integration', () => { + it('should return documents unsorted when no sort field set', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }), + createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }), + createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + expect(result.current.sortField).toBeNull() + expect(result.current.sortedDocuments).toHaveLength(3) + }) + + it('should sort by name descending', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'Banana.txt' }), + createDoc({ id: 'doc-2', name: 'Apple.txt' }), + createDoc({ id: 'doc-3', name: 'Cherry.txt' }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.sortField).toBe('name') + expect(result.current.sortOrder).toBe('desc') + const names = result.current.sortedDocuments.map(d => d.name) + expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt']) + }) + + it('should toggle sort order on same field click', () => { + const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + act(() => result.current.handleSort('name')) + expect(result.current.sortOrder).toBe('desc') + + act(() => result.current.handleSort('name')) + expect(result.current.sortOrder).toBe('asc') + }) + + it('should filter by status before sorting', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }), + createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }), + createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: 'available', + remoteSortValue: '-created_at', + })) + + // Only 'available' documents should remain + expect(result.current.sortedDocuments).toHaveLength(2) + expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true) + }) + }) + + describe('Document Selection Integration', () => { + it('should manage selection state externally', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + createDoc({ id: 'doc-3' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + })) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should select all documents', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + })) + + act(() => { + result.current.onSelectAll() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith( + expect.arrayContaining(['doc-1', 'doc-2']), + ) + }) + + it('should detect all-selected state', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.isAllSelected).toBe(true) + }) + + it('should detect partial selection', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + createDoc({ id: 'doc-3' }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should identify downloadable selected documents (FILE type only)', () => { + const docs = [ + createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }), + createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.downloadableSelectedIds).toEqual(['doc-1']) + }) + + it('should clear selection', () => { + const onSelectedIdChange = vi.fn() + const docs = [createDoc({ id: 'doc-1' })] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange, + })) + + act(() => { + result.current.clearSelection() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Cross-Module: Query State → Sort → Selection Pipeline', () => { + it('should maintain consistent default state across all hooks', () => { + const docs = [createDoc({ id: 'doc-1' })] + const { result: queryResult } = renderHook(() => useDocumentListQueryState()) + const { result: sortResult } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: queryResult.current.query.status, + remoteSortValue: queryResult.current.query.sort, + })) + const { result: selResult } = renderHook(() => useDocumentSelection({ + documents: sortResult.current.sortedDocuments, + selectedIds: [], + onSelectedIdChange: vi.fn(), + })) + + // Query defaults + expect(queryResult.current.query.sort).toBe('-created_at') + expect(queryResult.current.query.status).toBe('all') + + // Sort inherits 'all' status → no filtering applied + expect(sortResult.current.sortedDocuments).toHaveLength(1) + + // Selection starts empty + expect(selResult.current.isAllSelected).toBe(false) + }) + }) +}) diff --git a/web/__tests__/datasets/external-knowledge-base.test.tsx b/web/__tests__/datasets/external-knowledge-base.test.tsx new file mode 100644 index 0000000000..9c2b0da19d --- /dev/null +++ b/web/__tests__/datasets/external-knowledge-base.test.tsx @@ -0,0 +1,215 @@ +/** + * Integration Test: External Knowledge Base Creation Flow + * + * Tests the data contract, validation logic, and API interaction + * for external knowledge base creation. + */ + +import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' +import { describe, expect, it } from 'vitest' + +// --- Factory --- +const createFormData = (overrides?: Partial): CreateKnowledgeBaseReq => ({ + name: 'My External KB', + description: 'A test external knowledge base', + external_knowledge_api_id: 'api-1', + external_knowledge_id: 'ext-kb-123', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + ...overrides, +}) + +describe('External Knowledge Base Creation Flow', () => { + describe('Data Contract: CreateKnowledgeBaseReq', () => { + it('should define a complete form structure', () => { + const form = createFormData() + + expect(form).toHaveProperty('name') + expect(form).toHaveProperty('external_knowledge_api_id') + expect(form).toHaveProperty('external_knowledge_id') + expect(form).toHaveProperty('external_retrieval_model') + expect(form).toHaveProperty('provider') + expect(form.provider).toBe('external') + }) + + it('should include retrieval model settings', () => { + const form = createFormData() + + expect(form.external_retrieval_model).toEqual({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }) + }) + + it('should allow partial overrides', () => { + const form = createFormData({ + name: 'Custom Name', + external_retrieval_model: { + top_k: 10, + score_threshold: 0.8, + score_threshold_enabled: true, + }, + }) + + expect(form.name).toBe('Custom Name') + expect(form.external_retrieval_model.top_k).toBe(10) + expect(form.external_retrieval_model.score_threshold_enabled).toBe(true) + }) + }) + + describe('Form Validation Logic', () => { + const isFormValid = (form: CreateKnowledgeBaseReq): boolean => { + return ( + form.name.trim() !== '' + && form.external_knowledge_api_id !== '' + && form.external_knowledge_id !== '' + && form.external_retrieval_model.top_k !== undefined + && form.external_retrieval_model.score_threshold !== undefined + ) + } + + it('should validate a complete form', () => { + const form = createFormData() + expect(isFormValid(form)).toBe(true) + }) + + it('should reject empty name', () => { + const form = createFormData({ name: '' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject whitespace-only name', () => { + const form = createFormData({ name: ' ' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject empty external_knowledge_api_id', () => { + const form = createFormData({ external_knowledge_api_id: '' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject empty external_knowledge_id', () => { + const form = createFormData({ external_knowledge_id: '' }) + expect(isFormValid(form)).toBe(false) + }) + }) + + describe('Form State Transitions', () => { + it('should start with empty default state', () => { + const defaultForm: CreateKnowledgeBaseReq = { + name: '', + description: '', + external_knowledge_api_id: '', + external_knowledge_id: '', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + } + + // Verify default state matches component's initial useState + expect(defaultForm.name).toBe('') + expect(defaultForm.external_knowledge_api_id).toBe('') + expect(defaultForm.external_knowledge_id).toBe('') + expect(defaultForm.provider).toBe('external') + }) + + it('should support immutable form updates', () => { + const form = createFormData({ name: '' }) + const updated = { ...form, name: 'Updated Name' } + + expect(form.name).toBe('') + expect(updated.name).toBe('Updated Name') + // Other fields should remain unchanged + expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id) + }) + + it('should support retrieval model updates', () => { + const form = createFormData() + const updated = { + ...form, + external_retrieval_model: { + ...form.external_retrieval_model, + top_k: 10, + score_threshold_enabled: true, + }, + } + + expect(updated.external_retrieval_model.top_k).toBe(10) + expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true) + // Unchanged field + expect(updated.external_retrieval_model.score_threshold).toBe(0.5) + }) + }) + + describe('API Call Data Contract', () => { + it('should produce a valid API payload from form data', () => { + const form = createFormData() + + // The API expects the full CreateKnowledgeBaseReq + expect(form.name).toBeTruthy() + expect(form.external_knowledge_api_id).toBeTruthy() + expect(form.external_knowledge_id).toBeTruthy() + expect(form.provider).toBe('external') + expect(typeof form.external_retrieval_model.top_k).toBe('number') + expect(typeof form.external_retrieval_model.score_threshold).toBe('number') + expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean') + }) + + it('should support optional description', () => { + const formWithDesc = createFormData({ description: 'Some description' }) + const formWithoutDesc = createFormData({ description: '' }) + + expect(formWithDesc.description).toBe('Some description') + expect(formWithoutDesc.description).toBe('') + }) + + it('should validate retrieval model bounds', () => { + const form = createFormData({ + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + }) + + expect(form.external_retrieval_model.top_k).toBe(0) + expect(form.external_retrieval_model.score_threshold).toBe(0) + }) + }) + + describe('External API List Integration', () => { + it('should validate API item structure', () => { + const apiItem = { + id: 'api-1', + name: 'Production API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'key-123', + }, + } + + expect(apiItem).toHaveProperty('id') + expect(apiItem).toHaveProperty('name') + expect(apiItem).toHaveProperty('settings') + expect(apiItem.settings).toHaveProperty('endpoint') + expect(apiItem.settings).toHaveProperty('api_key') + }) + + it('should link API selection to form data', () => { + const selectedApi = { id: 'api-2', name: 'Staging API' } + const form = createFormData({ + external_knowledge_api_id: selectedApi.id, + }) + + expect(form.external_knowledge_api_id).toBe('api-2') + }) + }) +}) diff --git a/web/__tests__/datasets/hit-testing-flow.test.tsx b/web/__tests__/datasets/hit-testing-flow.test.tsx new file mode 100644 index 0000000000..93d6f77d8f --- /dev/null +++ b/web/__tests__/datasets/hit-testing-flow.test.tsx @@ -0,0 +1,404 @@ +/** + * Integration Test: Hit Testing Flow + * + * Tests the query submission → API response → callback chain flow + * by rendering the actual QueryInput component and triggering user interactions. + * Validates that the production onSubmit logic correctly constructs payloads + * and invokes callbacks on success/failure. + */ + +import type { + HitTestingResponse, + Query, +} from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import QueryInput from '@/app/components/datasets/hit-testing/components/query-input' +import { RETRIEVE_METHOD } from '@/types/app' + +// --- Mocks --- + +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })), + useDatasetDetailContextWithSelector: vi.fn(() => false), +})) + +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({})), + useContextSelector: vi.fn(() => false), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( +
+ {textArea} + {actionButton} +
+ ), +})) + +// --- Factories --- + +const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +} as RetrievalConfig) + +const createHitTestingResponse = (numResults: number): HitTestingResponse => ({ + query: { + content: 'What is Dify?', + tsne_position: { x: 0, y: 0 }, + }, + records: Array.from({ length: numResults }, (_, i) => ({ + segment: { + id: `seg-${i}`, + document: { + id: `doc-${i}`, + data_source_type: 'upload_file', + name: `document-${i}.txt`, + doc_type: null as unknown as import('@/models/datasets').DocType, + }, + content: `Result content ${i}`, + sign_content: `Result content ${i}`, + position: i + 1, + word_count: 100 + i * 50, + tokens: 50 + i * 25, + keywords: ['test', 'dify'], + hit_count: i * 5, + index_node_hash: `hash-${i}`, + answer: '', + }, + content: { + id: `seg-${i}`, + document: { + id: `doc-${i}`, + data_source_type: 'upload_file', + name: `document-${i}.txt`, + doc_type: null as unknown as import('@/models/datasets').DocType, + }, + content: `Result content ${i}`, + sign_content: `Result content ${i}`, + position: i + 1, + word_count: 100 + i * 50, + tokens: 50 + i * 25, + keywords: ['test', 'dify'], + hit_count: i * 5, + index_node_hash: `hash-${i}`, + answer: '', + }, + score: 0.95 - i * 0.1, + tsne_position: { x: 0, y: 0 }, + child_chunks: null, + files: [], + })), +}) + +const createTextQuery = (content: string): Query[] => [ + { content, content_type: 'text_query', file_info: null }, +] + +// --- Helpers --- + +const findSubmitButton = () => { + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + expect(submitButton).toBeTruthy() + return submitButton! +} + +// --- Tests --- + +describe('Hit Testing Flow', () => { + const mockHitTestingMutation = vi.fn() + const mockExternalMutation = vi.fn() + const mockSetHitResult = vi.fn() + const mockSetExternalHitResult = vi.fn() + const mockOnUpdateList = vi.fn() + const mockSetQueries = vi.fn() + const mockOnClickRetrievalMethod = vi.fn() + const mockOnSubmit = vi.fn() + + const createDefaultProps = (overrides: Record = {}) => ({ + onUpdateList: mockOnUpdateList, + setHitResult: mockSetHitResult, + setExternalHitResult: mockSetExternalHitResult, + loading: false, + queries: [] as Query[], + setQueries: mockSetQueries, + isExternal: false, + onClickRetrievalMethod: mockOnClickRetrievalMethod, + retrievalConfig: createRetrievalConfig(), + isEconomy: false, + onSubmit: mockOnSubmit, + hitTestingMutation: mockHitTestingMutation, + externalKnowledgeBaseHitTestingMutation: mockExternalMutation, + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Query Submission → API Call', () => { + it('should call hitTestingMutation with correct payload including retrieval model', async () => { + const retrievalConfig = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 3, + score_threshold_enabled: false, + }) + mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3)) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'How does RAG work?', + attachment_ids: [], + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 3, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + }) + + it('should override search_method to keywordSearch when isEconomy is true', async () => { + const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }) + mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1)) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.keywordSearch, + }), + }), + expect.anything(), + ) + }) + }) + + it('should handle empty results by calling setHitResult with empty records', async () => { + const emptyResponse = createHitTestingResponse(0) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(emptyResponse) + return emptyResponse + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetHitResult).toHaveBeenCalledWith( + expect.objectContaining({ records: [] }), + ) + }) + }) + + it('should not call success callbacks when mutation resolves without onSuccess', async () => { + // Simulate a mutation that resolves but does not invoke the onSuccess callback + mockHitTestingMutation.mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalled() + }) + // Success callbacks should not fire when onSuccess is not invoked + expect(mockSetHitResult).not.toHaveBeenCalled() + expect(mockOnUpdateList).not.toHaveBeenCalled() + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + }) + + describe('API Response → Results Data Contract', () => { + it('should produce results with required segment fields for rendering', () => { + const response = createHitTestingResponse(3) + + // Validate each result has the fields needed by ResultItem component + response.records.forEach((record) => { + expect(record.segment).toHaveProperty('id') + expect(record.segment).toHaveProperty('content') + expect(record.segment).toHaveProperty('position') + expect(record.segment).toHaveProperty('word_count') + expect(record.segment).toHaveProperty('document') + expect(record.segment.document).toHaveProperty('name') + expect(record.score).toBeGreaterThanOrEqual(0) + expect(record.score).toBeLessThanOrEqual(1) + }) + }) + + it('should maintain correct score ordering', () => { + const response = createHitTestingResponse(5) + + for (let i = 1; i < response.records.length; i++) { + expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score) + } + }) + + it('should include document metadata for result item display', () => { + const response = createHitTestingResponse(1) + const record = response.records[0] + + expect(record.segment.document.name).toBeTruthy() + expect(record.segment.document.data_source_type).toBeTruthy() + }) + }) + + describe('Successful Submission → Callback Chain', () => { + it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => { + const response = createHitTestingResponse(3) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetHitResult).toHaveBeenCalledWith(response) + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + expect(mockOnSubmit).toHaveBeenCalledTimes(1) + }) + }) + + it('should trigger records list refresh via onUpdateList after query', async () => { + const response = createHitTestingResponse(1) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('External KB Hit Testing', () => { + it('should use external mutation with correct payload for external datasets', async () => { + mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { + const response = { records: [] } + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockExternalMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'test', + external_retrieval_model: expect.objectContaining({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + // Internal mutation should NOT be called + expect(mockHitTestingMutation).not.toHaveBeenCalled() + }) + }) + + it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { + const externalResponse = { records: [] } + mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { + options?.onSuccess?.(externalResponse) + return externalResponse + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse) + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/web/__tests__/datasets/metadata-management-flow.test.tsx b/web/__tests__/datasets/metadata-management-flow.test.tsx new file mode 100644 index 0000000000..d8403f0f21 --- /dev/null +++ b/web/__tests__/datasets/metadata-management-flow.test.tsx @@ -0,0 +1,337 @@ +/** + * Integration Test: Metadata Management Flow + * + * Tests the cross-module composition of metadata name validation, type constraints, + * and duplicate detection across the metadata management hooks. + * + * The unit-level use-check-metadata-name.spec.ts tests the validation hook alone. + * This integration test verifies: + * - Name validation combined with existing metadata list (duplicate detection) + * - Metadata type enum constraints matching expected data model + * - Full add/rename workflow: validate name → check duplicates → allow or reject + * - Name uniqueness logic: existing metadata keeps its own name, cannot take another's + */ + +import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import { renderHook } from '@testing-library/react' +import { DataType } from '@/app/components/datasets/metadata/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const { default: useCheckMetadataName } = await import( + '@/app/components/datasets/metadata/hooks/use-check-metadata-name', +) + +// --- Factory functions --- + +const createMetadataItem = ( + id: string, + name: string, + type = DataType.string, + count = 0, +): MetadataItemWithValueLength => ({ + id, + name, + type, + count, +}) + +const createMetadataList = (): MetadataItemWithValueLength[] => [ + createMetadataItem('meta-1', 'author', DataType.string, 5), + createMetadataItem('meta-2', 'created_date', DataType.time, 10), + createMetadataItem('meta-3', 'page_count', DataType.number, 3), + createMetadataItem('meta-4', 'source_url', DataType.string, 8), + createMetadataItem('meta-5', 'version', DataType.number, 2), +] + +describe('Metadata Management Flow - Cross-Module Validation Composition', () => { + describe('Name Validation Flow: Format Rules', () => { + it('should accept valid lowercase names with underscores', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('valid_name').errorMsg).toBe('') + expect(result.current.checkName('author').errorMsg).toBe('') + expect(result.current.checkName('page_count').errorMsg).toBe('') + expect(result.current.checkName('v2_field').errorMsg).toBe('') + }) + + it('should reject empty names', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('').errorMsg).toBeTruthy() + }) + + it('should reject names with invalid characters', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('Author').errorMsg).toBeTruthy() + expect(result.current.checkName('my-field').errorMsg).toBeTruthy() + expect(result.current.checkName('field name').errorMsg).toBeTruthy() + expect(result.current.checkName('1field').errorMsg).toBeTruthy() + expect(result.current.checkName('_private').errorMsg).toBeTruthy() + }) + + it('should reject names exceeding 255 characters', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + const longName = 'a'.repeat(256) + expect(result.current.checkName(longName).errorMsg).toBeTruthy() + + const maxName = 'a'.repeat(255) + expect(result.current.checkName(maxName).errorMsg).toBe('') + }) + }) + + describe('Metadata Type Constraints: Enum Values Match Expected Set', () => { + it('should define exactly three data types', () => { + const typeValues = Object.values(DataType) + expect(typeValues).toHaveLength(3) + }) + + it('should include string, number, and time types', () => { + expect(DataType.string).toBe('string') + expect(DataType.number).toBe('number') + expect(DataType.time).toBe('time') + }) + + it('should use consistent types in metadata items', () => { + const metadataList = createMetadataList() + + const stringItems = metadataList.filter(m => m.type === DataType.string) + const numberItems = metadataList.filter(m => m.type === DataType.number) + const timeItems = metadataList.filter(m => m.type === DataType.time) + + expect(stringItems).toHaveLength(2) + expect(numberItems).toHaveLength(2) + expect(timeItems).toHaveLength(1) + }) + + it('should enforce type-safe metadata item construction', () => { + const item = createMetadataItem('test-1', 'test_field', DataType.number, 0) + + expect(item.id).toBe('test-1') + expect(item.name).toBe('test_field') + expect(item.type).toBe(DataType.number) + expect(item.count).toBe(0) + }) + }) + + describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => { + it('should detect duplicate names against an existing metadata list', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const checkDuplicate = (newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return existingMetadata.some(m => m.name === newName) + } + + expect(checkDuplicate('author')).toBe(true) + expect(checkDuplicate('created_date')).toBe(true) + expect(checkDuplicate('page_count')).toBe(true) + }) + + it('should allow names that do not conflict with existing metadata', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isNameAvailable = (newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName) + } + + expect(isNameAvailable('category')).toBe(true) + expect(isNameAvailable('file_size')).toBe(true) + expect(isNameAvailable('language')).toBe(true) + }) + + it('should reject names that fail format validation before duplicate check', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return { valid: false, reason: 'format' } + return { valid: true, reason: '' } + } + + expect(validateAndCheckDuplicate('Author').reason).toBe('format') + expect(validateAndCheckDuplicate('').reason).toBe('format') + expect(validateAndCheckDuplicate('valid_name').valid).toBe(true) + }) + }) + + describe('Name Uniqueness Across Edits: Rename Workflow', () => { + it('should allow an existing metadata item to keep its own name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + // Allow keeping the same name (skip self in duplicate check) + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + // Author keeping its own name should be valid + expect(isRenameValid('meta-1', 'author')).toBe(true) + // page_count keeping its own name should be valid + expect(isRenameValid('meta-3', 'page_count')).toBe(true) + }) + + it('should reject renaming to another existing metadata name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + // Author trying to rename to "page_count" (taken by meta-3) + expect(isRenameValid('meta-1', 'page_count')).toBe(false) + // version trying to rename to "source_url" (taken by meta-4) + expect(isRenameValid('meta-5', 'source_url')).toBe(false) + }) + + it('should allow renaming to a completely new valid name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + expect(isRenameValid('meta-1', 'document_author')).toBe(true) + expect(isRenameValid('meta-2', 'publish_date')).toBe(true) + expect(isRenameValid('meta-3', 'total_pages')).toBe(true) + }) + + it('should reject renaming with an invalid format even if name is unique', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + expect(isRenameValid('meta-1', 'New Author')).toBe(false) + expect(isRenameValid('meta-2', '2024_date')).toBe(false) + expect(isRenameValid('meta-3', '')).toBe(false) + }) + }) + + describe('Full Metadata Management Workflow', () => { + it('should support a complete add-validate-check-duplicate cycle', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const addMetadataField = ( + name: string, + type: DataType, + ): { success: boolean, error?: string } => { + const formatCheck = result.current.checkName(name) + if (formatCheck.errorMsg) + return { success: false, error: 'invalid_format' } + + if (existingMetadata.some(m => m.name === name)) + return { success: false, error: 'duplicate_name' } + + existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type)) + return { success: true } + } + + // Add a valid new field + const result1 = addMetadataField('department', DataType.string) + expect(result1.success).toBe(true) + expect(existingMetadata).toHaveLength(6) + + // Try to add a duplicate + const result2 = addMetadataField('author', DataType.string) + expect(result2.success).toBe(false) + expect(result2.error).toBe('duplicate_name') + expect(existingMetadata).toHaveLength(6) + + // Try to add an invalid name + const result3 = addMetadataField('Invalid Name', DataType.string) + expect(result3.success).toBe(false) + expect(result3.error).toBe('invalid_format') + expect(existingMetadata).toHaveLength(6) + + // Add another valid field + const result4 = addMetadataField('priority_level', DataType.number) + expect(result4.success).toBe(true) + expect(existingMetadata).toHaveLength(7) + }) + + it('should support a complete rename workflow with validation chain', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const renameMetadataField = ( + itemId: string, + newName: string, + ): { success: boolean, error?: string } => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return { success: false, error: 'invalid_format' } + + if (existingMetadata.some(m => m.name === newName && m.id !== itemId)) + return { success: false, error: 'duplicate_name' } + + const item = existingMetadata.find(m => m.id === itemId) + if (!item) + return { success: false, error: 'not_found' } + + // Simulate the rename in-place + const index = existingMetadata.indexOf(item) + existingMetadata[index] = { ...item, name: newName } + return { success: true } + } + + // Rename author to document_author + expect(renameMetadataField('meta-1', 'document_author').success).toBe(true) + expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author') + + // Try renaming created_date to page_count (already taken) + expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name') + + // Rename to invalid format + expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format') + + // Rename non-existent item + expect(renameMetadataField('meta-999', 'something').error).toBe('not_found') + }) + + it('should maintain validation consistency across multiple operations', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + // Validate the same name multiple times for consistency + const name = 'consistent_field' + const results = Array.from({ length: 5 }, () => result.current.checkName(name)) + + expect(results.every(r => r.errorMsg === '')).toBe(true) + + // Validate an invalid name multiple times + const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid')) + expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true) + }) + }) +}) diff --git a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx new file mode 100644 index 0000000000..dc140e8514 --- /dev/null +++ b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx @@ -0,0 +1,477 @@ +/** + * Integration Test: Pipeline Data Source Store Composition + * + * Tests cross-slice interactions in the pipeline data source Zustand store. + * The unit-level slice specs test each slice in isolation. + * This integration test verifies: + * - Store initialization produces correct defaults across all slices + * - Cross-slice coordination (e.g. credential shared across slices) + * - State isolation: changes in one slice do not affect others + * - Full workflow simulation through credential → source → data path + */ + +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { CrawlStep } from '@/models/datasets' +import { OnlineDriveFileType } from '@/models/pipeline' + +// --- Factory functions --- + +const createFileItem = (id: string): FileItem => ({ + fileID: id, + file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'], + progress: 100, +}) + +const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({ + title: title ?? `Page: ${url}`, + markdown: `# ${title ?? url}\n\nContent for ${url}`, + description: `Description for ${url}`, + source_url: url, +}) + +const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({ + id, + name, + size: 2048, + type, +}) + +const createNotionPage = (pageId: string): NotionPage => ({ + page_id: pageId, + page_name: `Page ${pageId}`, + page_icon: null, + is_bound: true, + parent_id: 'parent-1', + type: 'page', + workspace_id: 'ws-1', +}) + +describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => { + describe('Store Initialization → All Slices Have Correct Defaults', () => { + it('should create a store with all five slices combined', () => { + const store = createDataSourceStore() + const state = store.getState() + + // Common slice defaults + expect(state.currentCredentialId).toBe('') + expect(state.currentNodeIdRef.current).toBe('') + + // Local file slice defaults + expect(state.localFileList).toEqual([]) + expect(state.currentLocalFile).toBeUndefined() + + // Online document slice defaults + expect(state.documentsData).toEqual([]) + expect(state.onlineDocuments).toEqual([]) + expect(state.searchValue).toBe('') + expect(state.selectedPagesId).toEqual(new Set()) + + // Website crawl slice defaults + expect(state.websitePages).toEqual([]) + expect(state.step).toBe(CrawlStep.init) + expect(state.previewIndex).toBe(-1) + + // Online drive slice defaults + expect(state.breadcrumbs).toEqual([]) + expect(state.prefix).toEqual([]) + expect(state.keywords).toBe('') + expect(state.selectedFileIds).toEqual([]) + expect(state.onlineDriveFileList).toEqual([]) + expect(state.bucket).toBe('') + expect(state.hasBucket).toBe(false) + }) + }) + + describe('Cross-Slice Coordination: Shared Credential', () => { + it('should set credential that is accessible from the common slice', () => { + const store = createDataSourceStore() + + store.getState().setCurrentCredentialId('cred-abc-123') + + expect(store.getState().currentCredentialId).toBe('cred-abc-123') + }) + + it('should allow credential update independently of all other slices', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('f1')]) + store.getState().setCurrentCredentialId('cred-xyz') + + expect(store.getState().currentCredentialId).toBe('cred-xyz') + expect(store.getState().localFileList).toHaveLength(1) + }) + }) + + describe('Local File Workflow: Set Files → Verify List → Clear', () => { + it('should set and retrieve local file list', () => { + const store = createDataSourceStore() + const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')] + + store.getState().setLocalFileList(files) + + expect(store.getState().localFileList).toHaveLength(3) + expect(store.getState().localFileList[0].fileID).toBe('f1') + expect(store.getState().localFileList[2].fileID).toBe('f3') + }) + + it('should update preview ref when setting file list', () => { + const store = createDataSourceStore() + const files = [createFileItem('f-preview')] + + store.getState().setLocalFileList(files) + + expect(store.getState().previewLocalFileRef.current).toBeDefined() + }) + + it('should clear files by setting empty list', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('f1')]) + expect(store.getState().localFileList).toHaveLength(1) + + store.getState().setLocalFileList([]) + expect(store.getState().localFileList).toHaveLength(0) + }) + + it('should set and clear current local file selection', () => { + const store = createDataSourceStore() + const file = { id: 'current-file', name: 'current.txt' } as FileItem['file'] + + store.getState().setCurrentLocalFile(file) + expect(store.getState().currentLocalFile).toBeDefined() + expect(store.getState().currentLocalFile?.id).toBe('current-file') + + store.getState().setCurrentLocalFile(undefined) + expect(store.getState().currentLocalFile).toBeUndefined() + }) + }) + + describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => { + it('should set documents data and online documents', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('page-1'), createNotionPage('page-2')] + + store.getState().setOnlineDocuments(pages) + + expect(store.getState().onlineDocuments).toHaveLength(2) + expect(store.getState().onlineDocuments[0].page_id).toBe('page-1') + }) + + it('should update preview ref when setting online documents', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('page-preview')] + + store.getState().setOnlineDocuments(pages) + + expect(store.getState().previewOnlineDocumentRef.current).toBeDefined() + expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview') + }) + + it('should track selected page IDs', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')] + + store.getState().setOnlineDocuments(pages) + store.getState().setSelectedPagesId(new Set(['p1', 'p3'])) + + expect(store.getState().selectedPagesId.size).toBe(2) + expect(store.getState().selectedPagesId.has('p1')).toBe(true) + expect(store.getState().selectedPagesId.has('p2')).toBe(false) + expect(store.getState().selectedPagesId.has('p3')).toBe(true) + }) + + it('should manage search value for filtering documents', () => { + const store = createDataSourceStore() + + store.getState().setSearchValue('meeting notes') + + expect(store.getState().searchValue).toBe('meeting notes') + }) + + it('should set and clear current document selection', () => { + const store = createDataSourceStore() + const page = createNotionPage('current-page') + + store.getState().setCurrentDocument(page) + expect(store.getState().currentDocument?.page_id).toBe('current-page') + + store.getState().setCurrentDocument(undefined) + expect(store.getState().currentDocument).toBeUndefined() + }) + }) + + describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => { + it('should set website pages and update preview ref', () => { + const store = createDataSourceStore() + const pages = [ + createCrawlResultItem('https://example.com'), + createCrawlResultItem('https://example.com/about'), + ] + + store.getState().setWebsitePages(pages) + + expect(store.getState().websitePages).toHaveLength(2) + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com') + }) + + it('should manage crawl step transitions', () => { + const store = createDataSourceStore() + + expect(store.getState().step).toBe(CrawlStep.init) + + store.getState().setStep(CrawlStep.running) + expect(store.getState().step).toBe(CrawlStep.running) + + store.getState().setStep(CrawlStep.finished) + expect(store.getState().step).toBe(CrawlStep.finished) + }) + + it('should set crawl result with data and timing', () => { + const store = createDataSourceStore() + const result = { + data: [createCrawlResultItem('https://test.com')], + time_consuming: 3.5, + } + + store.getState().setCrawlResult(result) + + expect(store.getState().crawlResult?.data).toHaveLength(1) + expect(store.getState().crawlResult?.time_consuming).toBe(3.5) + }) + + it('should manage preview index for page navigation', () => { + const store = createDataSourceStore() + + store.getState().setPreviewIndex(2) + expect(store.getState().previewIndex).toBe(2) + + store.getState().setPreviewIndex(-1) + expect(store.getState().previewIndex).toBe(-1) + }) + + it('should set and clear current website selection', () => { + const store = createDataSourceStore() + const page = createCrawlResultItem('https://current.com') + + store.getState().setCurrentWebsite(page) + expect(store.getState().currentWebsite?.source_url).toBe('https://current.com') + + store.getState().setCurrentWebsite(undefined) + expect(store.getState().currentWebsite).toBeUndefined() + }) + }) + + describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => { + it('should manage breadcrumb navigation', () => { + const store = createDataSourceStore() + + store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder']) + + expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder']) + }) + + it('should support breadcrumb push/pop pattern', () => { + const store = createDataSourceStore() + + store.getState().setBreadcrumbs(['root']) + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1']) + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2']) + + expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2']) + + // Pop back one level + store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1)) + expect(store.getState().breadcrumbs).toEqual(['root', 'level-1']) + }) + + it('should manage file list and selection', () => { + const store = createDataSourceStore() + const files = [ + createOnlineDriveFile('drive-1', 'report.pdf'), + createOnlineDriveFile('drive-2', 'data.csv'), + createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder), + ] + + store.getState().setOnlineDriveFileList(files) + expect(store.getState().onlineDriveFileList).toHaveLength(3) + + store.getState().setSelectedFileIds(['drive-1', 'drive-2']) + expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2']) + }) + + it('should update preview ref when selecting files', () => { + const store = createDataSourceStore() + const files = [ + createOnlineDriveFile('drive-a', 'file-a.txt'), + createOnlineDriveFile('drive-b', 'file-b.txt'), + ] + + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['drive-b']) + + expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b') + }) + + it('should manage bucket and prefix for S3-like navigation', () => { + const store = createDataSourceStore() + + store.getState().setBucket('my-data-bucket') + store.getState().setPrefix(['data', '2024']) + store.getState().setHasBucket(true) + + expect(store.getState().bucket).toBe('my-data-bucket') + expect(store.getState().prefix).toEqual(['data', '2024']) + expect(store.getState().hasBucket).toBe(true) + }) + + it('should manage keywords for search filtering', () => { + const store = createDataSourceStore() + + store.getState().setKeywords('quarterly report') + expect(store.getState().keywords).toBe('quarterly report') + }) + }) + + describe('State Isolation: Changes to One Slice Do Not Affect Others', () => { + it('should keep local file state independent from online document state', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('local-1')]) + store.getState().setOnlineDocuments([createNotionPage('notion-1')]) + + expect(store.getState().localFileList).toHaveLength(1) + expect(store.getState().onlineDocuments).toHaveLength(1) + + // Clearing local files should not affect online documents + store.getState().setLocalFileList([]) + expect(store.getState().localFileList).toHaveLength(0) + expect(store.getState().onlineDocuments).toHaveLength(1) + }) + + it('should keep website crawl state independent from online drive state', () => { + const store = createDataSourceStore() + + store.getState().setWebsitePages([createCrawlResultItem('https://site.com')]) + store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')]) + + expect(store.getState().websitePages).toHaveLength(1) + expect(store.getState().onlineDriveFileList).toHaveLength(1) + + // Clearing website pages should not affect drive files + store.getState().setWebsitePages([]) + expect(store.getState().websitePages).toHaveLength(0) + expect(store.getState().onlineDriveFileList).toHaveLength(1) + }) + + it('should create fully independent store instances', () => { + const storeA = createDataSourceStore() + const storeB = createDataSourceStore() + + storeA.getState().setCurrentCredentialId('cred-A') + storeA.getState().setLocalFileList([createFileItem('fa-1')]) + + expect(storeA.getState().currentCredentialId).toBe('cred-A') + expect(storeB.getState().currentCredentialId).toBe('') + expect(storeB.getState().localFileList).toEqual([]) + }) + }) + + describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => { + it('should support a complete local file upload workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('upload-cred-1') + + // Step 2: Set file list + const files = [createFileItem('upload-1'), createFileItem('upload-2')] + store.getState().setLocalFileList(files) + + // Step 3: Select current file for preview + store.getState().setCurrentLocalFile(files[0].file) + + // Verify all state is consistent + expect(store.getState().currentCredentialId).toBe('upload-cred-1') + expect(store.getState().localFileList).toHaveLength(2) + expect(store.getState().currentLocalFile?.id).toBe('upload-1') + expect(store.getState().previewLocalFileRef.current).toBeDefined() + }) + + it('should support a complete website crawl workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('crawl-cred-1') + + // Step 2: Init crawl + store.getState().setStep(CrawlStep.running) + + // Step 3: Crawl completes with results + const crawledPages = [ + createCrawlResultItem('https://docs.example.com/guide'), + createCrawlResultItem('https://docs.example.com/api'), + createCrawlResultItem('https://docs.example.com/faq'), + ] + store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 }) + store.getState().setStep(CrawlStep.finished) + + // Step 4: Set website pages from results + store.getState().setWebsitePages(crawledPages) + + // Step 5: Set preview + store.getState().setPreviewIndex(1) + + // Verify all state + expect(store.getState().currentCredentialId).toBe('crawl-cred-1') + expect(store.getState().step).toBe(CrawlStep.finished) + expect(store.getState().websitePages).toHaveLength(3) + expect(store.getState().crawlResult?.time_consuming).toBe(12.5) + expect(store.getState().previewIndex).toBe(1) + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide') + }) + + it('should support a complete online drive navigation workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('drive-cred-1') + + // Step 2: Set bucket + store.getState().setBucket('company-docs') + store.getState().setHasBucket(true) + + // Step 3: Navigate into folders + store.getState().setBreadcrumbs(['company-docs']) + store.getState().setPrefix(['projects']) + const folderFiles = [ + createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder), + createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder), + createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file), + ] + store.getState().setOnlineDriveFileList(folderFiles) + + // Step 4: Navigate deeper + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha']) + store.getState().setPrefix([...store.getState().prefix, 'project-alpha']) + + // Step 5: Select files + store.getState().setOnlineDriveFileList([ + createOnlineDriveFile('doc-1', 'spec.pdf'), + createOnlineDriveFile('doc-2', 'design.fig'), + ]) + store.getState().setSelectedFileIds(['doc-1']) + + // Verify full state + expect(store.getState().currentCredentialId).toBe('drive-cred-1') + expect(store.getState().bucket).toBe('company-docs') + expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha']) + expect(store.getState().prefix).toEqual(['projects', 'project-alpha']) + expect(store.getState().onlineDriveFileList).toHaveLength(2) + expect(store.getState().selectedFileIds).toEqual(['doc-1']) + expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf') + }) + }) +}) diff --git a/web/__tests__/datasets/segment-crud.test.tsx b/web/__tests__/datasets/segment-crud.test.tsx new file mode 100644 index 0000000000..9190e17395 --- /dev/null +++ b/web/__tests__/datasets/segment-crud.test.tsx @@ -0,0 +1,301 @@ +/** + * Integration Test: Segment CRUD Flow + * + * Tests segment selection, search/filter, and modal state management across hooks. + * Validates cross-hook data contracts in the completed segment module. + */ + +import type { SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state' +import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter' +import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection' + +const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({ + id, + position: 1, + document_id: 'doc-1', + content, + sign_content: content, + answer: '', + word_count: 50, + tokens: 25, + keywords: ['test'], + index_node_id: 'idx-1', + index_node_hash: 'hash-1', + hit_count: 0, + enabled: true, + disabled_at: 0, + disabled_by: '', + status: 'completed', + created_by: 'user-1', + created_at: Date.now(), + indexing_at: Date.now(), + completed_at: Date.now(), + error: null, + stopped_at: 0, + updated_at: Date.now(), + attachments: [], +} as SegmentDetailModel) + +describe('Segment CRUD Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Search and Filter → Segment List Query', () => { + it('should manage search input with debounce', () => { + vi.useFakeTimers() + const onPageChange = vi.fn() + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('keyword') + }) + + expect(result.current.inputValue).toBe('keyword') + expect(result.current.searchValue).toBe('') + + act(() => { + vi.advanceTimersByTime(500) + }) + expect(result.current.searchValue).toBe('keyword') + expect(onPageChange).toHaveBeenCalledWith(1) + + vi.useRealTimers() + }) + + it('should manage status filter state', () => { + const onPageChange = vi.fn() + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + // status value 1 maps to !!1 = true (enabled) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + // onChangeStatus converts: value === 'all' ? 'all' : !!value + expect(result.current.selectedStatus).toBe(true) + + act(() => { + result.current.onClearFilter() + }) + expect(result.current.selectedStatus).toBe('all') + expect(result.current.inputValue).toBe('') + }) + + it('should provide status list for filter dropdown', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + expect(result.current.statusList).toBeInstanceOf(Array) + expect(result.current.statusList.length).toBe(3) // all, disabled, enabled + }) + + it('should compute selectDefaultValue based on selectedStatus', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + + // Initial state: 'all' + expect(result.current.selectDefaultValue).toBe('all') + + // Set to enabled (true) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + expect(result.current.selectDefaultValue).toBe(1) + + // Set to disabled (false) + act(() => { + result.current.onChangeStatus({ value: 0, name: 'disabled' }) + }) + expect(result.current.selectDefaultValue).toBe(0) + }) + }) + + describe('Segment Selection → Batch Operations', () => { + const segments = [ + createSegment('seg-1'), + createSegment('seg-2'), + createSegment('seg-3'), + ] + + it('should manage individual segment selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + + act(() => { + result.current.onSelected('seg-2') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + expect(result.current.selectedSegmentIds).toContain('seg-2') + expect(result.current.selectedSegmentIds).toHaveLength(2) + }) + + it('should toggle selection on repeated click', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).not.toContain('seg-1') + }) + + it('should support select all toggle', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + expect(result.current.selectedSegmentIds).toHaveLength(3) + expect(result.current.isAllSelected).toBe(true) + + act(() => { + result.current.onSelectedAll() + }) + expect(result.current.selectedSegmentIds).toHaveLength(0) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should detect partial selection via isSomeSelected', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + + // After selecting one of three, isSomeSelected should be true + expect(result.current.selectedSegmentIds).toEqual(['seg-1']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should clear selection via onCancelBatchOperation', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + result.current.onSelected('seg-2') + }) + expect(result.current.selectedSegmentIds).toHaveLength(2) + + act(() => { + result.current.onCancelBatchOperation() + }) + expect(result.current.selectedSegmentIds).toHaveLength(0) + }) + }) + + describe('Modal State Management', () => { + const onNewSegmentModalChange = vi.fn() + + it('should open segment detail modal on card click', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + const segment = createSegment('seg-detail-1', 'Detail content') + act(() => { + result.current.onClickCard(segment) + }) + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBeDefined() + expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1') + }) + + it('should close segment detail modal', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + const segment = createSegment('seg-1') + act(() => { + result.current.onClickCard(segment) + }) + expect(result.current.currSegment.showModal).toBe(true) + + act(() => { + result.current.onCloseSegmentDetail() + }) + expect(result.current.currSegment.showModal).toBe(false) + }) + + it('should manage full screen toggle', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.fullScreen).toBe(false) + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(true) + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(false) + }) + + it('should manage collapsed state', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.isCollapsed).toBe(true) + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(false) + }) + + it('should manage new child segment modal', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.showNewChildSegmentModal).toBe(false) + act(() => { + result.current.handleAddNewChildChunk('chunk-parent-1') + }) + expect(result.current.showNewChildSegmentModal).toBe(true) + expect(result.current.currChunkId).toBe('chunk-parent-1') + + act(() => { + result.current.onCloseNewChildChunkModal() + }) + expect(result.current.showNewChildSegmentModal).toBe(false) + }) + }) + + describe('Cross-Hook Data Flow: Search → Selection → Modal', () => { + it('should maintain independent state across all three hooks', () => { + const segments = [createSegment('seg-1'), createSegment('seg-2')] + + const { result: filterResult } = renderHook(() => + useSearchFilter({ onPageChange: vi.fn() }), + ) + const { result: selectionResult } = renderHook(() => + useSegmentSelection(segments), + ) + const { result: modalResult } = renderHook(() => + useModalState({ onNewSegmentModalChange: vi.fn() }), + ) + + // Set search filter to enabled + act(() => { + filterResult.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + + // Select a segment + act(() => { + selectionResult.current.onSelected('seg-1') + }) + + // Open detail modal + act(() => { + modalResult.current.onClickCard(segments[0]) + }) + + // All states should be independent + expect(filterResult.current.selectedStatus).toBe(true) // !!1 + expect(selectionResult.current.selectedSegmentIds).toContain('seg-1') + expect(modalResult.current.currSegment.showModal).toBe(true) + }) + }) +}) diff --git a/web/__tests__/develop/api-key-management-flow.test.tsx b/web/__tests__/develop/api-key-management-flow.test.tsx new file mode 100644 index 0000000000..188b8e6304 --- /dev/null +++ b/web/__tests__/develop/api-key-management-flow.test.tsx @@ -0,0 +1,192 @@ +/** + * Integration test: API Key management flow + * + * Tests the cross-component interaction: + * ApiServer → SecretKeyButton → SecretKeyModal + * + * Renders real ApiServer, SecretKeyButton, and SecretKeyModal together + * with only service-layer mocks. Deep modal interactions (create/delete) + * are covered by unit tests in secret-key-modal.spec.tsx. + */ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ApiServer from '@/app/components/develop/ApiServer' + +// ---------- fake timers (HeadlessUI Dialog transitions) ---------- +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +async function flushUI() { + await act(async () => { + vi.runAllTimers() + }) +} + +// ---------- mocks ---------- + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'ws-1', name: 'Workspace' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((val: number) => `Time:${val}`), + formatDate: vi.fn((val: string) => `Date:${val}`), + }), +})) + +vi.mock('@/service/apps', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/datasets', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +const mockApiKeys = vi.fn().mockReturnValue({ data: [] }) +const mockIsLoading = vi.fn().mockReturnValue(false) + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: () => ({ + data: mockApiKeys(), + isLoading: mockIsLoading(), + }), + useInvalidateAppApiKeys: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: () => ({ data: null, isLoading: false }), + useInvalidateDatasetApiKeys: () => vi.fn(), +})) + +// ---------- tests ---------- + +describe('API Key management flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApiKeys.mockReturnValue({ data: [] }) + mockIsLoading.mockReturnValue(false) + }) + + it('ApiServer renders URL, status badge, and API Key button', () => { + render() + + expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + }) + + it('clicking API Key button opens SecretKeyModal with real modal content', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render() + + // Click API Key button (rendered by SecretKeyButton) + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + // SecretKeyModal should render with real HeadlessUI Dialog + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() + }) + }) + + it('modal shows loading state when API keys are being fetched', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + mockIsLoading.mockReturnValue(true) + + render() + + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Loading indicator should be present + expect(document.body.querySelector('[role="status"]')).toBeInTheDocument() + }) + + it('modal can be closed by clicking X icon', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render() + + // Open modal + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Click X icon to close + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + + await act(async () => { + await user.click(closeIcon!) + }) + await flushUI() + + // Modal should close + await waitFor(() => { + expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument() + }) + }) + + it('renders correctly with different API URLs', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + const { rerender } = render( + , + ) + + expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument() + + // Open modal and verify it works with the same appId + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Close modal, update URL and re-verify + const xIcon = document.body.querySelector('svg.cursor-pointer') + await act(async () => { + await user.click(xIcon!) + }) + await flushUI() + + rerender( + , + ) + + expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/develop/develop-page-flow.test.tsx b/web/__tests__/develop/develop-page-flow.test.tsx new file mode 100644 index 0000000000..6b46ee025c --- /dev/null +++ b/web/__tests__/develop/develop-page-flow.test.tsx @@ -0,0 +1,241 @@ +/** + * Integration test: DevelopMain page flow + * + * Tests the full page lifecycle: + * Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered + * + * Uses real DevelopMain, ApiServer, and Doc components with minimal mocks. + */ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DevelopMain from '@/app/components/develop' +import { AppModeEnum, Theme } from '@/types/app' + +// ---------- fake timers ---------- +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +async function flushUI() { + await act(async () => { + vi.runAllTimers() + }) +} + +// ---------- store mock ---------- + +let storeAppDetail: unknown + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => { + return selector({ appDetail: storeAppDetail }) + }, +})) + +// ---------- Doc dependencies ---------- + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: Theme.light }), +})) + +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'], +})) + +// ---------- SecretKeyModal dependencies ---------- + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'ws-1', name: 'Workspace' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((val: number) => `Time:${val}`), + formatDate: vi.fn((val: string) => `Date:${val}`), + }), +})) + +vi.mock('@/service/apps', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/datasets', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }), + useInvalidateAppApiKeys: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: () => ({ data: null, isLoading: false }), + useInvalidateDatasetApiKeys: () => vi.fn(), +})) + +// ---------- tests ---------- + +describe('DevelopMain page flow', () => { + beforeEach(() => { + vi.clearAllMocks() + storeAppDetail = undefined + }) + + it('should show loading indicator when appDetail is not available', () => { + storeAppDetail = undefined + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + // No content should be visible + expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument() + }) + + it('should render full page when appDetail is loaded', () => { + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + render() + + // ApiServer section should be visible + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + + // Loading should NOT be visible + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('should render Doc component with correct app mode template', () => { + storeAppDetail = { + id: 'app-1', + name: 'Chat App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + const { container } = render() + + // Doc renders an article element with prose classes + const article = container.querySelector('article') + expect(article).toBeInTheDocument() + expect(article?.className).toContain('prose') + }) + + it('should transition from loading to content when appDetail becomes available', () => { + // Start with no data + storeAppDetail = undefined + const { rerender } = render() + expect(screen.getByRole('status')).toBeInTheDocument() + + // Simulate store update + storeAppDetail = { + id: 'app-1', + name: 'My App', + api_base_url: 'https://api.example.com/v1', + mode: AppModeEnum.COMPLETION, + } + rerender() + + // Content should now be visible + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument() + }) + + it('should open API key modal from the page', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.WORKFLOW, + } + + render() + + // Click API Key button in the header + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + // SecretKeyModal should open + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + }) + + it('should render correctly for different app modes', () => { + const modes = [ + AppModeEnum.CHAT, + AppModeEnum.COMPLETION, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.WORKFLOW, + ] + + for (const mode of modes) { + storeAppDetail = { + id: 'app-1', + name: `${mode} App`, + api_base_url: 'https://api.test.com/v1', + mode, + } + + const { container, unmount } = render() + + // ApiServer should always be present + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + + // Doc should render an article + expect(container.querySelector('article')).toBeInTheDocument() + + unmount() + } + }) + + it('should have correct page layout structure', () => { + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + render() + + // Main container: flex column with full height + const mainDiv = screen.getByTestId('develop-main') + expect(mainDiv.className).toContain('flex') + expect(mainDiv.className).toContain('flex-col') + expect(mainDiv.className).toContain('h-full') + + // Header section with border + const header = mainDiv.querySelector('.border-b') + expect(header).toBeInTheDocument() + + // Content section with overflow scroll + const content = mainDiv.querySelector('.overflow-auto') + expect(content).toBeInTheDocument() + }) +}) diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx similarity index 57% rename from web/app/components/explore/app-list/index.spec.tsx rename to web/__tests__/explore/explore-app-list-flow.test.tsx index a87d5a2363..1a54135420 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -1,18 +1,23 @@ +/** + * Integration test: Explore App List Flow + * + * Tests the end-to-end user flow of browsing, filtering, searching, + * and adding apps to workspace from the explore page. + */ import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AppList from '@/app/components/explore/app-list' import ExploreContext from '@/context/explore-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 mockExploreData: { categories: string[], allList: App[] } | undefined let mockIsLoading = false -let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() @@ -43,7 +48,7 @@ vi.mock('@/service/use-explore', () => ({ useExploreAppList: () => ({ data: mockExploreData, isLoading: mockIsLoading, - isError: mockIsError, + isError: false, }), })) @@ -96,7 +101,7 @@ vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ const createApp = (overrides: Partial = {}): App => ({ app: { - id: overrides.app?.id ?? 'app-basic-id', + id: overrides.app?.id ?? 'app-id', mode: overrides.app?.mode ?? AppModeEnum.CHAT, icon_type: overrides.app?.icon_type ?? 'emoji', icon: overrides.app?.icon ?? '😀', @@ -121,113 +126,80 @@ const createApp = (overrides: Partial = {}): App => ({ is_agent: overrides.is_agent ?? false, }) -const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => { - return render( - - - , - ) +const createContextValue = (hasEditPermission = true) => ({ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission, + installedApps: [] as never[], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), +}) + +const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => ( + + + +) + +const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => { + return render(wrapWithContext(hasEditPermission, onSuccess)) } -describe('AppList', () => { +describe('Explore App List Flow', () => { beforeEach(() => { vi.clearAllMocks() mockTabValue = allCategoriesEn - mockExploreData = { categories: [], allList: [] } mockIsLoading = false - mockIsError = false + mockExploreData = { + categories: ['Writing', 'Translate', 'Programming'], + allList: [ + createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), + createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }), + createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }), + ], + } }) - // Rendering: show loading when categories are not ready. - describe('Rendering', () => { - it('should render loading when the query is loading', () => { - // Arrange - mockExploreData = undefined - mockIsLoading = true - - // Act + describe('Browse and Filter Flow', () => { + it('should display all apps when no category filter is applied', () => { renderWithContext() - // Assert - expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByText('Writer Bot')).toBeInTheDocument() + expect(screen.getByText('Translator')).toBeInTheDocument() + expect(screen.getByText('Code Helper')).toBeInTheDocument() }) - it('should render app cards when data is available', () => { - // Arrange - mockExploreData = { - categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], - } - - // Act - renderWithContext() - - // Assert - expect(screen.getByText('Alpha')).toBeInTheDocument() - expect(screen.getByText('Beta')).toBeInTheDocument() - }) - }) - - // Props: category selection filters the list. - describe('Props', () => { it('should filter apps by selected category', () => { - // Arrange mockTabValue = 'Writing' - mockExploreData = { - categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], - } - - // Act renderWithContext() - // Assert - expect(screen.getByText('Alpha')).toBeInTheDocument() - expect(screen.queryByText('Beta')).not.toBeInTheDocument() + expect(screen.getByText('Writer Bot')).toBeInTheDocument() + expect(screen.queryByText('Translator')).not.toBeInTheDocument() + expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() }) - }) - // User interactions: search and create flow. - describe('User Interactions', () => { - it('should filter apps by search keywords', async () => { - // Arrange - mockExploreData = { - categories: ['Writing'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], - } + it('should filter apps by search keyword', async () => { renderWithContext() - // Act const input = screen.getByPlaceholderText('common.operation.search') - fireEvent.change(input, { target: { value: 'gam' } }) + fireEvent.change(input, { target: { value: 'trans' } }) - // Assert await waitFor(() => { - expect(screen.queryByText('Alpha')).not.toBeInTheDocument() - expect(screen.getByText('Gamma')).toBeInTheDocument() + expect(screen.getByText('Translator')).toBeInTheDocument() + expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument() + expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() }) }) + }) - it('should handle create flow and confirm DSL when pending', async () => { - // Arrange + describe('Add to Workspace Flow', () => { + it('should complete the full add-to-workspace flow with DSL confirmation', async () => { + // Step 1: User clicks "Add to Workspace" on an app card const onSuccess = vi.fn() - mockExploreData = { - categories: ['Writing'], - allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { options.onPending?.() }) @@ -235,19 +207,27 @@ describe('AppList', () => { options.onSuccess?.() }) - // Act renderWithContext(true, onSuccess) - fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) + + // Step 2: Click add to workspace button - opens create modal + fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0]) + + // Step 3: Confirm creation in modal fireEvent.click(await screen.findByTestId('confirm-create')) - // Assert + // Step 4: API fetches app detail await waitFor(() => { - expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id') + expect(fetchAppDetail).toHaveBeenCalledWith('app-id') }) - expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) - expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument() + // Step 5: DSL import triggers pending confirmation + expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) + + // Step 6: DSL confirm modal appears and user confirms + expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument() fireEvent.click(screen.getByTestId('dsl-confirm')) + + // Step 7: Flow completes successfully await waitFor(() => { expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) expect(onSuccess).toHaveBeenCalledTimes(1) @@ -255,30 +235,39 @@ describe('AppList', () => { }) }) - // Edge cases: handle clearing search keywords. - describe('Edge Cases', () => { - it('should reset search results when clear icon is clicked', async () => { - // Arrange + describe('Loading and Empty States', () => { + it('should transition from loading to content', () => { + // Step 1: Loading state + mockIsLoading = true + mockExploreData = undefined + const { rerender } = render(wrapWithContext()) + + expect(screen.getByRole('status')).toBeInTheDocument() + + // Step 2: Data loads + mockIsLoading = false mockExploreData = { categories: ['Writing'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], + allList: [createApp()], } - renderWithContext() + rerender(wrapWithContext()) - // Act - const input = screen.getByPlaceholderText('common.operation.search') - fireEvent.change(input, { target: { value: 'gam' } }) - await waitFor(() => { - expect(screen.queryByText('Alpha')).not.toBeInTheDocument() - }) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + }) + }) - fireEvent.click(screen.getByTestId('input-clear')) + describe('Permission-Based Behavior', () => { + it('should hide add-to-workspace button when user has no edit permission', () => { + renderWithContext(false) - // Assert - await waitFor(() => { - expect(screen.getByText('Alpha')).toBeInTheDocument() - expect(screen.getByText('Gamma')).toBeInTheDocument() - }) + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + }) + + it('should show add-to-workspace button when user has edit permission', () => { + renderWithContext(true) + + expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0) }) }) }) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx new file mode 100644 index 0000000000..69dcb116aa --- /dev/null +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -0,0 +1,260 @@ +/** + * Integration test: Installed App Flow + * + * Tests the end-to-end user flow of installed apps: sidebar navigation, + * mode-based routing (Chat / Completion / Workflow), and lifecycle + * operations (pin/unpin, delete). + */ +import type { Mock } from 'vitest' +import type { InstalledApp as InstalledAppModel } from '@/models/explore' +import { render, screen, waitFor } from '@testing-library/react' +import { useContext } from 'use-context-selector' +import InstalledApp from '@/app/components/explore/installed-app' +import { useWebAppStore } from '@/context/web-app-context' +import { AccessMode } from '@/models/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' +import { AppModeEnum } from '@/types/app' + +// Mock external dependencies +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: vi.fn(), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: vi.fn(), +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledAppAccessModeByAppId: vi.fn(), + useGetInstalledAppParams: vi.fn(), + useGetInstalledAppMeta: vi.fn(), +})) + +vi.mock('@/app/components/share/text-generation', () => ({ + default: ({ isWorkflow }: { isWorkflow?: boolean }) => ( +
+ Text Generation + {isWorkflow && ' (Workflow)'} +
+ ), +})) + +vi.mock('@/app/components/base/chat/chat-with-history', () => ({ + default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => ( +
+ Chat - + {' '} + {installedAppInfo?.app.name} +
+ ), +})) + +describe('Installed App Flow', () => { + const mockUpdateAppInfo = vi.fn() + const mockUpdateWebAppAccessMode = vi.fn() + const mockUpdateAppParams = vi.fn() + const mockUpdateWebAppMeta = vi.fn() + const mockUpdateUserCanAccessApp = vi.fn() + + const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({ + id: 'installed-app-1', + app: { + id: 'real-app-id', + name: 'Integration Test App', + mode, + icon_type: 'emoji', + icon: '🧪', + icon_background: '#FFFFFF', + icon_url: '', + description: 'Test app for integration', + use_icon_as_answer_icon: false, + }, + uninstallable: true, + is_pinned: false, + }) + + const mockAppParams = { + user_input_form: [], + file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } }, + system_parameters: {}, + } + + type MockOverrides = { + context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean } + accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown } + params?: { isFetching?: boolean, data?: unknown, error?: unknown } + meta?: { isFetching?: boolean, data?: unknown, error?: unknown } + userAccess?: { data?: unknown, error?: unknown } + } + + const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => { + ;(useContext as Mock).mockReturnValue({ + installedApps: app ? [app] : [], + isFetchingInstalledApps: false, + ...overrides.context, + }) + + ;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record) => unknown) => { + return selector({ + updateAppInfo: mockUpdateAppInfo, + updateWebAppAccessMode: mockUpdateWebAppAccessMode, + updateAppParams: mockUpdateAppParams, + updateWebAppMeta: mockUpdateWebAppMeta, + updateUserCanAccessApp: mockUpdateUserCanAccessApp, + }) + }) + + ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ + isFetching: false, + data: { accessMode: AccessMode.PUBLIC }, + error: null, + ...overrides.accessMode, + }) + + ;(useGetInstalledAppParams as Mock).mockReturnValue({ + isFetching: false, + data: mockAppParams, + error: null, + ...overrides.params, + }) + + ;(useGetInstalledAppMeta as Mock).mockReturnValue({ + isFetching: false, + data: { tool_icons: {} }, + error: null, + ...overrides.meta, + }) + + ;(useGetUserCanAccessApp as Mock).mockReturnValue({ + data: { result: true }, + error: null, + ...overrides.userAccess, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Mode-Based Routing', () => { + it.each([ + [AppModeEnum.CHAT, 'chat-with-history'], + [AppModeEnum.ADVANCED_CHAT, 'chat-with-history'], + [AppModeEnum.AGENT_CHAT, 'chat-with-history'], + ])('should render ChatWithHistory for %s mode', (mode, testId) => { + const app = createInstalledApp(mode) + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId(testId)).toBeInTheDocument() + expect(screen.getByText(/Integration Test App/)).toBeInTheDocument() + }) + + it('should render TextGenerationApp for COMPLETION mode', () => { + const app = createInstalledApp(AppModeEnum.COMPLETION) + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText('Text Generation')).toBeInTheDocument() + expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument() + }) + + it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => { + const app = createInstalledApp(AppModeEnum.WORKFLOW) + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText(/Workflow/)).toBeInTheDocument() + }) + }) + + describe('Data Loading Flow', () => { + it('should show loading spinner when params are being fetched', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { params: { isFetching: true, data: null } }) + + const { container } = render() + + expect(container.querySelector('svg.spin-animation')).toBeInTheDocument() + expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument() + }) + + it('should render content when all data is available', () => { + const app = createInstalledApp() + setupDefaultMocks(app) + + render() + + expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() + }) + }) + + describe('Error Handling Flow', () => { + it('should show error state when API fails', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } }) + + render() + + expect(screen.getByText(/Network error/)).toBeInTheDocument() + }) + + it('should show 404 when app is not found', () => { + setupDefaultMocks(undefined, { + accessMode: { data: null }, + params: { data: null }, + meta: { data: null }, + userAccess: { data: null }, + }) + + render() + + expect(screen.getByText(/404/)).toBeInTheDocument() + }) + + it('should show 403 when user has no permission', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { userAccess: { data: { result: false } } }) + + render() + + expect(screen.getByText(/403/)).toBeInTheDocument() + }) + }) + + describe('State Synchronization', () => { + it('should update all stores when app data is loaded', async () => { + const app = createInstalledApp() + setupDefaultMocks(app) + + render() + + await waitFor(() => { + expect(mockUpdateAppInfo).toHaveBeenCalledWith( + expect.objectContaining({ + app_id: 'installed-app-1', + site: expect.objectContaining({ + title: 'Integration Test App', + icon: '🧪', + }), + }), + ) + expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) + expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} }) + expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC) + expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true) + }) + }) + }) +}) diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx new file mode 100644 index 0000000000..bf4821ced4 --- /dev/null +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -0,0 +1,225 @@ +import type { IExplore } from '@/context/explore-context' +/** + * Integration test: Sidebar Lifecycle Flow + * + * Tests the sidebar interactions for installed apps lifecycle: + * navigation, pin/unpin ordering, delete confirmation, and + * fold/unfold behavior. + */ +import type { InstalledApp } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import SideBar from '@/app/components/explore/sidebar' +import ExploreContext from '@/context/explore-context' +import { MediaType } from '@/hooks/use-breakpoints' +import { AppModeEnum } from '@/types/app' + +let mockMediaType: string = MediaType.pc +const mockSegments = ['apps'] +const mockPush = vi.fn() +const mockRefetch = vi.fn() +const mockUninstall = vi.fn() +const mockUpdatePinStatus = vi.fn() +let mockInstalledApps: InstalledApp[] = [] + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegments: () => mockSegments, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => mockMediaType, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledApps: () => ({ + isFetching: false, + data: { installed_apps: mockInstalledApps }, + refetch: mockRefetch, + }), + useUninstallApp: () => ({ + mutateAsync: mockUninstall, + }), + useUpdateAppPinStatus: () => ({ + mutateAsync: mockUpdatePinStatus, + }), +})) + +const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ + id: overrides.id ?? 'app-1', + uninstallable: overrides.uninstallable ?? false, + is_pinned: overrides.is_pinned ?? false, + app: { + id: overrides.app?.id ?? 'app-basic-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '🤖', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'App One', + description: overrides.app?.description ?? 'desc', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, +}) + +const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: true, + installedApps, + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), +}) + +const renderSidebar = (installedApps: InstalledApp[] = []) => { + return render( + + + , + ) +} + +describe('Sidebar Lifecycle Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMediaType = MediaType.pc + mockInstalledApps = [] + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + describe('Pin / Unpin / Delete Flow', () => { + it('should complete pin → unpin cycle for an app', async () => { + mockUpdatePinStatus.mockResolvedValue(undefined) + + // Step 1: Start with an unpinned app and pin it + const unpinnedApp = createInstalledApp({ is_pinned: false }) + mockInstalledApps = [unpinnedApp] + const { unmount } = renderSidebar(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + // Step 2: Simulate refetch returning pinned state, then unpin + unmount() + vi.clearAllMocks() + mockUpdatePinStatus.mockResolvedValue(undefined) + + const pinnedApp = createInstalledApp({ is_pinned: true }) + mockInstalledApps = [pinnedApp] + renderSidebar(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + }) + + it('should complete the delete flow with confirmation', async () => { + const app = createInstalledApp() + mockInstalledApps = [app] + mockUninstall.mockResolvedValue(undefined) + + renderSidebar(mockInstalledApps) + + // Step 1: Open operation menu and click delete + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Step 2: Confirm dialog appears + expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() + + // Step 3: Confirm deletion + fireEvent.click(screen.getByText('common.operation.confirm')) + + // Step 4: Uninstall API called and success toast shown + await waitFor(() => { + expect(mockUninstall).toHaveBeenCalledWith('app-1') + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.api.remove', + })) + }) + }) + + it('should cancel deletion when user clicks cancel', async () => { + const app = createInstalledApp() + mockInstalledApps = [app] + + renderSidebar(mockInstalledApps) + + // Open delete flow + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Cancel the deletion + fireEvent.click(await screen.findByText('common.operation.cancel')) + + // Uninstall should not be called + expect(mockUninstall).not.toHaveBeenCalled() + }) + }) + + describe('Multi-App Ordering', () => { + it('should display pinned apps before unpinned apps with divider', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }), + createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }), + ] + + const { container } = renderSidebar(mockInstalledApps) + + // Both apps are rendered + const pinnedApp = screen.getByText('Pinned App') + const regularApp = screen.getByText('Regular App') + expect(pinnedApp).toBeInTheDocument() + expect(regularApp).toBeInTheDocument() + + // Pinned app appears before unpinned app in the DOM + const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')! + const regularItem = regularApp.closest('[class*="rounded-lg"]')! + expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + // Divider is rendered between pinned and unpinned sections + const divider = container.querySelector('[class*="bg-divider-regular"]') + expect(divider).toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show NoApps component when no apps are installed on desktop', () => { + mockMediaType = MediaType.pc + renderSidebar([]) + + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + }) + + it('should hide NoApps on mobile', () => { + mockMediaType = MediaType.mobile + renderSidebar([]) + + expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/goto-anything/slash-command-modes.test.tsx b/web/__tests__/goto-anything/slash-command-modes.test.tsx index 9a2f7c1eac..38c965e383 100644 --- a/web/__tests__/goto-anything/slash-command-modes.test.tsx +++ b/web/__tests__/goto-anything/slash-command-modes.test.tsx @@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => { beforeEach(() => { vi.clearAllMocks() - ;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => { + vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => { if (name === 'docs') return mockDirectCommand if (name === 'theme') return mockSubmenuCommand - return null + return undefined }) - ;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [ + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ mockDirectCommand, mockSubmenuCommand, ]) @@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => { unregister: vi.fn(), } - ;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode) + vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode) const handler = slashCommandRegistry.findCommand('test') // Default behavior should be submenu when mode is not specified diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx new file mode 100644 index 0000000000..a2ec8703ca --- /dev/null +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -0,0 +1,271 @@ +/** + * Integration Test: Plugin Authentication Flow + * + * Tests the integration between PluginAuth, usePluginAuth hook, + * Authorize/Authorized components, and credential management. + * Verifies the complete auth flow from checking authorization status + * to rendering the correct UI state. + */ +import { cleanup, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + 'plugin.auth.setUpTip': 'Set up your credentials', + 'plugin.auth.authorized': 'Authorized', + 'plugin.auth.apiKey': 'API Key', + 'plugin.auth.oauth': 'OAuth', + } + return map[key] ?? key + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +const mockUsePluginAuth = vi.fn() +vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({ + usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args), +})) + +vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({ + default: ({ pluginPayload, canOAuth, canApiKey }: { + pluginPayload: { provider: string } + canOAuth: boolean + canApiKey: boolean + }) => ( +
+ {pluginPayload.provider} + {canOAuth && OAuth available} + {canApiKey && API Key available} +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({ + default: ({ pluginPayload, credentials }: { + pluginPayload: { provider: string } + credentials: Array<{ id: string, name: string }> + }) => ( +
+ {pluginPayload.provider} + + {credentials.length} + {' '} + credentials + +
+ ), +})) + +const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth') + +describe('Plugin Authentication Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + }) + + const basePayload = { + category: AuthCategory.tool, + provider: 'test-provider', + } + + describe('Unauthorized State', () => { + it('renders Authorize component when not authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.getByTestId('authorize-component')).toBeInTheDocument() + expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument() + expect(screen.getByTestId('auth-apikey')).toBeInTheDocument() + }) + + it('shows OAuth option when plugin supports it', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: true, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.getByTestId('auth-oauth')).toBeInTheDocument() + expect(screen.getByTestId('auth-apikey')).toBeInTheDocument() + }) + + it('applies className to wrapper when not authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('custom-class') + }) + }) + + describe('Authorized State', () => { + it('renders Authorized component when authorized and no children', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [ + { id: 'cred-1', name: 'My API Key', is_default: true }, + ], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument() + expect(screen.getByTestId('authorized-component')).toBeInTheDocument() + expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials') + }) + + it('renders children instead of Authorized when authorized and children provided', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render( + +
Custom authorized view
+
, + ) + + expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument() + expect(screen.getByTestId('custom-children')).toBeInTheDocument() + }) + + it('does not apply className when authorized', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: false, + canApiKey: true, + credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const { container } = render( + , + ) + + expect(container.firstChild).not.toHaveClass('custom-class') + }) + }) + + describe('Auth Category Integration', () => { + it('passes correct provider to usePluginAuth for tool category', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: false, + canApiKey: true, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const toolPayload = { + category: AuthCategory.tool, + provider: 'google-search-provider', + } + + render() + + expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true) + expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider') + }) + + it('passes correct provider to usePluginAuth for datasource category', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: false, + canOAuth: true, + canApiKey: false, + credentials: [], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + const dsPayload = { + category: AuthCategory.datasource, + provider: 'notion-datasource', + } + + render() + + expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true) + expect(screen.getByTestId('auth-oauth')).toBeInTheDocument() + expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument() + }) + }) + + describe('Multiple Credentials', () => { + it('shows credential count when multiple credentials exist', () => { + mockUsePluginAuth.mockReturnValue({ + isAuthorized: true, + canOAuth: true, + canApiKey: true, + credentials: [ + { id: 'cred-1', name: 'API Key 1', is_default: true }, + { id: 'cred-2', name: 'API Key 2', is_default: false }, + { id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 }, + ], + disabled: false, + invalidPluginCredentialInfo: vi.fn(), + notAllowCustomCredential: false, + }) + + render() + + expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx new file mode 100644 index 0000000000..7abcb01b49 --- /dev/null +++ b/web/__tests__/plugins/plugin-card-rendering.test.tsx @@ -0,0 +1,224 @@ +/** + * Integration Test: Plugin Card Rendering Pipeline + * + * Tests the integration between Card, Icon, Title, Description, + * OrgInfo, CornerMark, and CardMoreInfo components. Verifies that + * plugin data flows correctly through the card rendering pipeline. + */ +import { cleanup, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (obj: Record, locale: string) => obj[locale] || obj.en_US || '', +})) + +vi.mock('@/types/app', () => ({ + Theme: { dark: 'dark', light: 'light' }, +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useCategories: () => ({ + categoriesMap: { + tool: { label: 'Tool' }, + model: { label: 'Model' }, + extension: { label: 'Extension' }, + }, + }), +})) + +vi.mock('@/app/components/plugins/base/badges/partner', () => ({ + default: () => Partner, +})) + +vi.mock('@/app/components/plugins/base/badges/verified', () => ({ + default: () => Verified, +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => ( +
+ {typeof src === 'string' ? src : 'emoji-icon'} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({ + default: ({ text }: { text: string }) => ( +
{text}
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => ( +
{text}
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( +
+ {orgName} + / + {packageName} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/placeholder', () => ({ + default: ({ text }: { text: string }) => ( +
{text}
+ ), +})) + +vi.mock('@/app/components/plugins/card/base/title', () => ({ + default: ({ title }: { title: string }) => ( +
{title}
+ ), +})) + +const { default: Card } = await import('@/app/components/plugins/card/index') +type CardPayload = Parameters[0]['payload'] + +describe('Plugin Card Rendering Integration', () => { + beforeEach(() => { + cleanup() + }) + + const makePayload = (overrides = {}) => ({ + category: 'tool', + type: 'plugin', + name: 'google-search', + org: 'langgenius', + label: { en_US: 'Google Search', zh_Hans: 'Google搜索' }, + brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' }, + icon: 'https://example.com/icon.png', + verified: true, + badges: [] as string[], + ...overrides, + }) as CardPayload + + it('renders a complete plugin card with all subcomponents', () => { + const payload = makePayload() + render() + + expect(screen.getByTestId('card-icon')).toBeInTheDocument() + expect(screen.getByTestId('title')).toHaveTextContent('Google Search') + expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search') + expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google') + }) + + it('shows corner mark with category label when not hidden', () => { + const payload = makePayload() + render() + + expect(screen.getByTestId('corner-mark')).toBeInTheDocument() + }) + + it('hides corner mark when hideCornerMark is true', () => { + const payload = makePayload() + render() + + expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument() + }) + + it('shows installed status on icon', () => { + const payload = makePayload() + render() + + const icon = screen.getByTestId('card-icon') + expect(icon).toHaveAttribute('data-installed', 'true') + }) + + it('shows install failed status on icon', () => { + const payload = makePayload() + render() + + const icon = screen.getByTestId('card-icon') + expect(icon).toHaveAttribute('data-install-failed', 'true') + }) + + it('renders verified badge when plugin is verified', () => { + const payload = makePayload({ verified: true }) + render() + + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('renders partner badge when plugin has partner badge', () => { + const payload = makePayload({ badges: ['partner'] }) + render() + + expect(screen.getByTestId('partner-badge')).toBeInTheDocument() + }) + + it('renders footer content when provided', () => { + const payload = makePayload() + render( + Custom footer
} + />, + ) + + expect(screen.getByTestId('custom-footer')).toBeInTheDocument() + }) + + it('renders titleLeft content when provided', () => { + const payload = makePayload() + render( + New} + />, + ) + + expect(screen.getByTestId('title-left-content')).toBeInTheDocument() + }) + + it('uses dark icon when theme is dark and icon_dark is provided', () => { + vi.doMock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'dark' }), + })) + + const payload = makePayload({ + icon: 'https://example.com/icon-light.png', + icon_dark: 'https://example.com/icon-dark.png', + }) + + render() + expect(screen.getByTestId('card-icon')).toBeInTheDocument() + }) + + it('shows loading placeholder when isLoading is true', () => { + const payload = makePayload() + render() + + expect(screen.getByTestId('placeholder')).toBeInTheDocument() + }) + + it('renders description with custom line rows', () => { + const payload = makePayload() + render() + + const description = screen.getByTestId('description') + expect(description).toHaveAttribute('data-rows', '3') + }) +}) diff --git a/web/__tests__/plugins/plugin-data-utilities.test.ts b/web/__tests__/plugins/plugin-data-utilities.test.ts new file mode 100644 index 0000000000..068b0e3238 --- /dev/null +++ b/web/__tests__/plugins/plugin-data-utilities.test.ts @@ -0,0 +1,159 @@ +/** + * Integration Test: Plugin Data Utilities + * + * Tests the integration between plugin utility functions, including + * tag/category validation, form schema transformation, and + * credential data processing. Verifies that these utilities work + * correctly together in processing plugin metadata. + */ +import { describe, expect, it } from 'vitest' + +import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils' +import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils' + +type TagInput = Parameters[0] + +describe('Plugin Data Utilities Integration', () => { + describe('Tag and Category Validation Pipeline', () => { + it('validates tags and categories in a metadata processing flow', () => { + const pluginMetadata = { + tags: ['search', 'productivity', 'invalid-tag', 'media-generate'], + category: 'tool', + } + + const validTags = getValidTagKeys(pluginMetadata.tags as TagInput) + expect(validTags.length).toBeGreaterThan(0) + expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length) + + const validCategory = getValidCategoryKeys(pluginMetadata.category) + expect(validCategory).toBeDefined() + }) + + it('handles completely invalid metadata gracefully', () => { + const invalidMetadata = { + tags: ['nonexistent-1', 'nonexistent-2'], + category: 'nonexistent-category', + } + + const validTags = getValidTagKeys(invalidMetadata.tags as TagInput) + expect(validTags).toHaveLength(0) + + const validCategory = getValidCategoryKeys(invalidMetadata.category) + expect(validCategory).toBeUndefined() + }) + + it('handles undefined and empty inputs', () => { + expect(getValidTagKeys([] as TagInput)).toHaveLength(0) + expect(getValidCategoryKeys(undefined)).toBeUndefined() + expect(getValidCategoryKeys('')).toBeUndefined() + }) + }) + + describe('Credential Secret Masking Pipeline', () => { + it('masks secrets when displaying credential form data', () => { + const credentialValues = { + api_key: 'sk-abc123456789', + api_endpoint: 'https://api.example.com', + secret_token: 'secret-token-value', + description: 'My credential set', + } + + const secretFields = ['api_key', 'secret_token'] + + const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues) + + expect(displayValues.api_key).toBe('[__HIDDEN__]') + expect(displayValues.secret_token).toBe('[__HIDDEN__]') + expect(displayValues.api_endpoint).toBe('https://api.example.com') + expect(displayValues.description).toBe('My credential set') + }) + + it('preserves original values when no secret fields', () => { + const values = { + name: 'test', + endpoint: 'https://api.example.com', + } + + const result = transformFormSchemasSecretInput([], values) + expect(result).toEqual(values) + }) + + it('handles falsy secret values without masking', () => { + const values = { + api_key: '', + secret: null as unknown as string, + other: 'visible', + } + + const result = transformFormSchemasSecretInput(['api_key', 'secret'], values) + expect(result.api_key).toBe('') + expect(result.secret).toBeNull() + expect(result.other).toBe('visible') + }) + + it('does not mutate the original values object', () => { + const original = { + api_key: 'my-secret-key', + name: 'test', + } + const originalCopy = { ...original } + + transformFormSchemasSecretInput(['api_key'], original) + + expect(original).toEqual(originalCopy) + }) + }) + + describe('Combined Plugin Metadata Validation', () => { + it('processes a complete plugin entry with tags and credentials', () => { + const pluginEntry = { + name: 'test-plugin', + category: 'tool', + tags: ['search', 'invalid-tag'], + credentials: { + api_key: 'sk-test-key-123', + base_url: 'https://api.test.com', + }, + secretFields: ['api_key'], + } + + const validCategory = getValidCategoryKeys(pluginEntry.category) + expect(validCategory).toBe('tool') + + const validTags = getValidTagKeys(pluginEntry.tags as TagInput) + expect(validTags).toContain('search') + + const displayCredentials = transformFormSchemasSecretInput( + pluginEntry.secretFields, + pluginEntry.credentials, + ) + expect(displayCredentials.api_key).toBe('[__HIDDEN__]') + expect(displayCredentials.base_url).toBe('https://api.test.com') + + expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123') + }) + + it('handles multiple plugins in batch processing', () => { + const plugins = [ + { tags: ['search', 'productivity'], category: 'tool' }, + { tags: ['image', 'design'], category: 'model' }, + { tags: ['invalid'], category: 'extension' }, + ] + + const results = plugins.map(p => ({ + validTags: getValidTagKeys(p.tags as TagInput), + validCategory: getValidCategoryKeys(p.category), + })) + + expect(results[0].validTags.length).toBeGreaterThan(0) + expect(results[0].validCategory).toBe('tool') + + expect(results[1].validTags).toContain('image') + expect(results[1].validTags).toContain('design') + expect(results[1].validCategory).toBe('model') + + expect(results[2].validTags).toHaveLength(0) + expect(results[2].validCategory).toBe('extension') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts new file mode 100644 index 0000000000..7ceca4535b --- /dev/null +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -0,0 +1,269 @@ +/** + * Integration Test: Plugin Installation Flow + * + * Tests the integration between GitHub release fetching, version comparison, + * upload handling, and task status polling. Verifies the complete plugin + * installation pipeline from source discovery to completion. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/config', () => ({ + GITHUB_ACCESS_TOKEN: '', +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (...args: unknown[]) => mockToastNotify(...args) }, +})) + +const mockUploadGitHub = vi.fn() +vi.mock('@/service/plugins', () => ({ + uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args), + checkTaskStatus: vi.fn(), +})) + +vi.mock('@/utils/semver', () => ({ + compareVersion: (a: string, b: string) => { + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) + const [aMajor, aMinor = 0, aPatch = 0] = parse(a) + const [bMajor, bMinor = 0, bPatch = 0] = parse(b) + if (aMajor !== bMajor) + return aMajor > bMajor ? 1 : -1 + if (aMinor !== bMinor) + return aMinor > bMinor ? 1 : -1 + if (aPatch !== bPatch) + return aPatch > bPatch ? 1 : -1 + return 0 + }, + getLatestVersion: (versions: string[]) => { + return versions.sort((a, b) => { + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) + const [aMaj, aMin = 0, aPat = 0] = parse(a) + const [bMaj, bMin = 0, bPat = 0] = parse(b) + if (aMaj !== bMaj) + return bMaj - aMaj + if (aMin !== bMin) + return bMin - aMin + return bPat - aPat + })[0] + }, +})) + +const { useGitHubReleases, useGitHubUpload } = await import( + '@/app/components/plugins/install-plugin/hooks', +) + +describe('Plugin Installation Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + globalThis.fetch = vi.fn() + }) + + describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => { + it('fetches releases, checks for updates, and uploads the new version', async () => { + const mockReleases = [ + { + tag_name: 'v2.0.0', + assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }], + }, + { + tag_name: 'v1.5.0', + assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }], + }, + { + tag_name: 'v1.0.0', + assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }], + }, + ] + + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockReleases), + }) + + mockUploadGitHub.mockResolvedValue({ + manifest: { name: 'test-plugin', version: '2.0.0' }, + unique_identifier: 'test-plugin:2.0.0', + }) + + const { fetchReleases, checkForUpdates } = useGitHubReleases() + + const releases = await fetchReleases('test-org', 'test-repo') + expect(releases).toHaveLength(3) + expect(releases[0].tag_name).toBe('v2.0.0') + + const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') + expect(needUpdate).toBe(true) + expect(toastProps.message).toContain('v2.0.0') + + const { handleUpload } = useGitHubUpload() + const onSuccess = vi.fn() + const result = await handleUpload( + 'https://github.com/test-org/test-repo', + 'v2.0.0', + 'plugin-v2.difypkg', + onSuccess, + ) + + expect(mockUploadGitHub).toHaveBeenCalledWith( + 'https://github.com/test-org/test-repo', + 'v2.0.0', + 'plugin-v2.difypkg', + ) + expect(onSuccess).toHaveBeenCalledWith({ + manifest: { name: 'test-plugin', version: '2.0.0' }, + unique_identifier: 'test-plugin:2.0.0', + }) + expect(result).toEqual({ + manifest: { name: 'test-plugin', version: '2.0.0' }, + unique_identifier: 'test-plugin:2.0.0', + }) + }) + + it('handles no new version available', async () => { + const mockReleases = [ + { + tag_name: 'v1.0.0', + assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }], + }, + ] + + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockReleases), + }) + + const { fetchReleases, checkForUpdates } = useGitHubReleases() + + const releases = await fetchReleases('test-org', 'test-repo') + const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') + + expect(needUpdate).toBe(false) + expect(toastProps.type).toBe('info') + expect(toastProps.message).toBe('No new version available') + }) + + it('handles empty releases', async () => { + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + + const { fetchReleases, checkForUpdates } = useGitHubReleases() + + const releases = await fetchReleases('test-org', 'test-repo') + expect(releases).toHaveLength(0) + + const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') + expect(needUpdate).toBe(false) + expect(toastProps.type).toBe('error') + expect(toastProps.message).toBe('Input releases is empty') + }) + + it('handles fetch failure gracefully', async () => { + ;(globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 404, + }) + + const { fetchReleases } = useGitHubReleases() + const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo') + + expect(releases).toEqual([]) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('handles upload failure gracefully', async () => { + mockUploadGitHub.mockRejectedValue(new Error('Upload failed')) + + const { handleUpload } = useGitHubUpload() + const onSuccess = vi.fn() + + await expect( + handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess), + ).rejects.toThrow('Upload failed') + + expect(onSuccess).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'Error uploading package' }), + ) + }) + }) + + describe('Task Status Polling Integration', () => { + it('polls until plugin installation succeeds', async () => { + const mockCheckTaskStatus = vi.fn() + .mockResolvedValueOnce({ + task: { + plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }], + }, + }) + .mockResolvedValueOnce({ + task: { + plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }], + }, + }) + + const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') + ;(fetchCheckTaskStatus as ReturnType).mockImplementation(mockCheckTaskStatus) + + await vi.doMock('@/utils', () => ({ + sleep: () => Promise.resolve(), + })) + + const { default: checkTaskStatus } = await import( + '@/app/components/plugins/install-plugin/base/check-task-status', + ) + + const checker = checkTaskStatus() + const result = await checker.check({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test:1.0.0', + }) + + expect(result.status).toBe('success') + }) + + it('returns failure when plugin not found in task', async () => { + const mockCheckTaskStatus = vi.fn().mockResolvedValue({ + task: { + plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }], + }, + }) + + const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') + ;(fetchCheckTaskStatus as ReturnType).mockImplementation(mockCheckTaskStatus) + + const { default: checkTaskStatus } = await import( + '@/app/components/plugins/install-plugin/base/check-task-status', + ) + + const checker = checkTaskStatus() + const result = await checker.check({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test:1.0.0', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Plugin package not found') + }) + + it('stops polling when stop() is called', async () => { + const { default: checkTaskStatus } = await import( + '@/app/components/plugins/install-plugin/base/check-task-status', + ) + + const checker = checkTaskStatus() + checker.stop() + + const result = await checker.check({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test:1.0.0', + }) + + expect(result.status).toBe('success') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx new file mode 100644 index 0000000000..91e32155e7 --- /dev/null +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' +import { InstallationScope } from '@/types/feature' + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: () => ({ + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.ALL, + }, + }), +})) + +describe('Plugin Marketplace to Install Flow', () => { + describe('install permission validation pipeline', () => { + const systemFeaturesAll = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + const systemFeaturesMarketplaceOnly = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + const systemFeaturesOfficialOnly = { + plugin_installation_permission: { + restrict_to_marketplace_only: false, + plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, + }, + } + + it('should allow marketplace plugin when all sources allowed', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) + expect(result.canInstall).toBe(true) + }) + + it('should allow github plugin when all sources allowed', () => { + const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) + expect(result.canInstall).toBe(true) + }) + + it('should block github plugin when marketplace only', () => { + const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) + expect(result.canInstall).toBe(false) + }) + + it('should allow marketplace plugin when marketplace only', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) + expect(result.canInstall).toBe(true) + }) + + it('should allow official plugin when official only', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) + expect(result.canInstall).toBe(true) + }) + + it('should block community plugin when official only', () => { + const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } } + const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) + expect(result.canInstall).toBe(false) + }) + }) + + describe('plugin source classification', () => { + it('should correctly classify plugin install sources', () => { + const sources = ['marketplace', 'github', 'package'] as const + const features = { + plugin_installation_permission: { + restrict_to_marketplace_only: true, + plugin_installation_scope: InstallationScope.ALL, + }, + } + + const results = sources.map(source => ({ + source, + canInstall: pluginInstallLimit( + { from: source, verification: { authorized_category: 'langgenius' } } as never, + features as never, + ).canInstall, + })) + + expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true) + expect(results.find(r => r.source === 'github')?.canInstall).toBe(false) + expect(results.find(r => r.source === 'package')?.canInstall).toBe(false) + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-page-filter-management.test.tsx b/web/__tests__/plugins/plugin-page-filter-management.test.tsx new file mode 100644 index 0000000000..9f6fbabc31 --- /dev/null +++ b/web/__tests__/plugins/plugin-page-filter-management.test.tsx @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store' + +describe('Plugin Page Filter Management Integration', () => { + beforeEach(() => { + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList([]) + result.current.setCategoryList([]) + result.current.setShowTagManagementModal(false) + result.current.setShowCategoryManagementModal(false) + }) + }) + + describe('tag and category filter lifecycle', () => { + it('should manage full tag lifecycle: add -> update -> clear', () => { + const { result } = renderHook(() => useStore()) + + const initialTags = [ + { name: 'search', label: { en_US: 'Search' } }, + { name: 'productivity', label: { en_US: 'Productivity' } }, + ] + + act(() => { + result.current.setTagList(initialTags as never[]) + }) + expect(result.current.tagList).toHaveLength(2) + + const updatedTags = [ + ...initialTags, + { name: 'image', label: { en_US: 'Image' } }, + ] + + act(() => { + result.current.setTagList(updatedTags as never[]) + }) + expect(result.current.tagList).toHaveLength(3) + + act(() => { + result.current.setTagList([]) + }) + expect(result.current.tagList).toHaveLength(0) + }) + + it('should manage full category lifecycle: add -> update -> clear', () => { + const { result } = renderHook(() => useStore()) + + const categories = [ + { name: 'tool', label: { en_US: 'Tool' } }, + { name: 'model', label: { en_US: 'Model' } }, + ] + + act(() => { + result.current.setCategoryList(categories as never[]) + }) + expect(result.current.categoryList).toHaveLength(2) + + act(() => { + result.current.setCategoryList([]) + }) + expect(result.current.categoryList).toHaveLength(0) + }) + }) + + describe('modal state management', () => { + it('should manage tag management modal independently', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowTagManagementModal(true) + }) + expect(result.current.showTagManagementModal).toBe(true) + expect(result.current.showCategoryManagementModal).toBe(false) + + act(() => { + result.current.setShowTagManagementModal(false) + }) + expect(result.current.showTagManagementModal).toBe(false) + }) + + it('should manage category management modal independently', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + expect(result.current.showCategoryManagementModal).toBe(true) + expect(result.current.showTagManagementModal).toBe(false) + }) + + it('should support both modals open simultaneously', () => { + const { result } = renderHook(() => useStore()) + + act(() => { + result.current.setShowTagManagementModal(true) + result.current.setShowCategoryManagementModal(true) + }) + + expect(result.current.showTagManagementModal).toBe(true) + expect(result.current.showCategoryManagementModal).toBe(true) + }) + }) + + describe('state persistence across renders', () => { + it('should maintain filter state when re-rendered', () => { + const { result, rerender } = renderHook(() => useStore()) + + act(() => { + result.current.setTagList([{ name: 'search' }] as never[]) + result.current.setCategoryList([{ name: 'tool' }] as never[]) + }) + + rerender() + + expect(result.current.tagList).toHaveLength(1) + expect(result.current.categoryList).toHaveLength(1) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts new file mode 100644 index 0000000000..c4cafbc1c5 --- /dev/null +++ b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts @@ -0,0 +1,210 @@ +/** + * Integration test: Chunk preview formatting pipeline + * + * Tests the formatPreviewChunks utility across all chunking modes + * (text, parentChild, QA) with real data structures. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/config', () => ({ + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3, +})) + +vi.mock('@/models/datasets', () => ({ + ChunkingMode: { + text: 'text', + parentChild: 'parent-child', + qa: 'qa', + }, +})) + +const { formatPreviewChunks } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils', +) + +describe('Chunk Preview Formatting', () => { + describe('general text chunks', () => { + it('should format text chunks correctly', () => { + const outputs = { + chunk_structure: 'text', + preview: [ + { content: 'Chunk 1 content', summary: 'Summary 1' }, + { content: 'Chunk 2 content' }, + ], + } + + const result = formatPreviewChunks(outputs) + + expect(Array.isArray(result)).toBe(true) + const chunks = result as Array<{ content: string, summary?: string }> + expect(chunks).toHaveLength(2) + expect(chunks[0].content).toBe('Chunk 1 content') + expect(chunks[0].summary).toBe('Summary 1') + expect(chunks[1].content).toBe('Chunk 2 content') + }) + + it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => { + const outputs = { + chunk_structure: 'text', + preview: Array.from({ length: 10 }, (_, i) => ({ + content: `Chunk ${i + 1}`, + })), + } + + const result = formatPreviewChunks(outputs) + const chunks = result as Array<{ content: string }> + + expect(chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('parent-child chunks — paragraph mode', () => { + it('should format paragraph parent-child chunks', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'paragraph', + preview: [ + { + content: 'Parent paragraph', + child_chunks: ['Child 1', 'Child 2'], + summary: 'Parent summary', + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ + parent_content: string + parent_summary?: string + child_contents: string[] + parent_mode: string + }> + parent_mode: string + } + + expect(result.parent_mode).toBe('paragraph') + expect(result.parent_child_chunks).toHaveLength(1) + expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph') + expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary') + expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2']) + }) + + it('should limit parent chunks in paragraph mode', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'paragraph', + preview: Array.from({ length: 10 }, (_, i) => ({ + content: `Parent ${i + 1}`, + child_chunks: [`Child of ${i + 1}`], + })), + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: unknown[] + } + + expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('parent-child chunks — full-doc mode', () => { + it('should format full-doc parent-child chunks', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'full-doc', + preview: [ + { + content: 'Full document content', + child_chunks: ['Section 1', 'Section 2', 'Section 3'], + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ + parent_content: string + child_contents: string[] + parent_mode: string + }> + } + + expect(result.parent_child_chunks).toHaveLength(1) + expect(result.parent_child_chunks[0].parent_content).toBe('Full document content') + expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc') + }) + + it('should limit child chunks in full-doc mode', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'full-doc', + preview: [ + { + content: 'Document', + child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`), + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ child_contents: string[] }> + } + + expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit + }) + }) + + describe('QA chunks', () => { + it('should format QA chunks correctly', () => { + const outputs = { + chunk_structure: 'qa', + qa_preview: [ + { question: 'What is AI?', answer: 'Artificial Intelligence is...' }, + { question: 'What is ML?', answer: 'Machine Learning is...' }, + ], + } + + const result = formatPreviewChunks(outputs) as { + qa_chunks: Array<{ question: string, answer: string }> + } + + expect(result.qa_chunks).toHaveLength(2) + expect(result.qa_chunks[0].question).toBe('What is AI?') + expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...') + }) + + it('should limit QA chunks', () => { + const outputs = { + chunk_structure: 'qa', + qa_preview: Array.from({ length: 10 }, (_, i) => ({ + question: `Q${i + 1}`, + answer: `A${i + 1}`, + })), + } + + const result = formatPreviewChunks(outputs) as { + qa_chunks: unknown[] + } + + expect(result.qa_chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('edge cases', () => { + it('should return undefined for null outputs', () => { + expect(formatPreviewChunks(null)).toBeUndefined() + }) + + it('should return undefined for undefined outputs', () => { + expect(formatPreviewChunks(undefined)).toBeUndefined() + }) + + it('should return undefined for unknown chunk_structure', () => { + const outputs = { + chunk_structure: 'unknown-type', + preview: [], + } + + expect(formatPreviewChunks(outputs)).toBeUndefined() + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts new file mode 100644 index 0000000000..578552840d --- /dev/null +++ b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts @@ -0,0 +1,179 @@ +/** + * Integration test: DSL export/import flow + * + * Validates DSL export logic (sync draft → check secrets → download) + * and DSL import modal state management. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined) +const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' }) +const mockNotify = vi.fn() +const mockEventEmitter = { emit: vi.fn() } +const mockDownloadBlob = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + pipelineId: 'pipeline-abc', + knowledgeName: 'My Pipeline', + }), + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: mockEventEmitter, + }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipelineConfig, + }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), +})) + +describe('DSL Export/Import Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Export Flow', () => { + it('should sync draft then export then download', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'My Pipeline.pipeline', + })) + }) + + it('should export with include flag when specified', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL(true) + }) + + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: true, + }) + }) + + it('should notify on export error', async () => { + mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed')) + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + describe('Export Check Flow', () => { + it('should export directly when no secret environment variables', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'string', key: 'API_URL', value: 'https://api.example.com' }, + ], + } as unknown as Awaited>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + // Should proceed to export directly (no secret vars) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + }) + + it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'secret', key: 'API_KEY', value: '***' }, + ], + } as unknown as Awaited>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'DSL_EXPORT_CHECK', + payload: expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ value_type: 'secret' }), + ]), + }), + })) + }) + + it('should notify on export check error', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed')) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts new file mode 100644 index 0000000000..233c9a288a --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test: Input field CRUD complete flow + * + * Validates the full lifecycle of input fields: + * creation, editing, renaming, removal, and data conversion round-trip. + */ +import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types' +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' + +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, +})) + +describe('Input Field CRUD Flow', () => { + describe('Create → Edit → Convert Round-trip', () => { + it('should create a text field and roundtrip through form data', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + // Create new field from template (no data passed) + const newFormData = convertToInputFieldFormData() + expect(newFormData.type).toBe('text-input') + expect(newFormData.variable).toBe('') + expect(newFormData.label).toBe('') + expect(newFormData.required).toBe(true) + + // Simulate user editing form data + const editedFormData: FormData = { + ...newFormData, + variable: 'user_name', + label: 'User Name', + maxLength: 100, + default: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + allowedTypesAndExtensions: {}, + } + + // Convert back to InputVar + const inputVar = convertFormDataToINputField(editedFormData) + + expect(inputVar.variable).toBe('user_name') + expect(inputVar.label).toBe('User Name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + expect(inputVar.placeholder).toBe('Type here...') + expect(inputVar.required).toBe(true) + }) + + it('should handle file field with upload settings', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fileInputVar: InputVar = { + type: PipelineInputVarType.singleFile, + label: 'Upload Document', + variable: 'doc_file', + max_length: 1, + default_value: undefined, + required: true, + tooltips: 'Upload a PDF', + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['.pdf', '.docx'], + } + + // Convert to form data + const formData = convertToInputFieldFormData(fileInputVar) + expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['.pdf', '.docx'], + }) + + // Round-trip back + const restored = convertFormDataToINputField(formData) + expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document]) + expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx']) + }) + + it('should handle select field with options', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const selectVar: InputVar = { + type: PipelineInputVarType.select, + label: 'Priority', + variable: 'priority', + max_length: 0, + default_value: 'medium', + required: false, + tooltips: 'Select priority level', + options: ['low', 'medium', 'high'], + placeholder: 'Choose...', + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(selectVar) + expect(formData.options).toEqual(['low', 'medium', 'high']) + expect(formData.default).toBe('medium') + + const restored = convertFormDataToINputField(formData) + expect(restored.options).toEqual(['low', 'medium', 'high']) + expect(restored.default_value).toBe('medium') + }) + + it('should handle number field with unit', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const numberVar: InputVar = { + type: PipelineInputVarType.number, + label: 'Max Tokens', + variable: 'max_tokens', + max_length: 0, + default_value: '1024', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'tokens', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(numberVar) + expect(formData.unit).toBe('tokens') + expect(formData.default).toBe('1024') + + const restored = convertFormDataToINputField(formData) + expect(restored.unit).toBe('tokens') + expect(restored.default_value).toBe('1024') + }) + }) + + describe('Omit optional fields', () => { + it('should not include tooltips when undefined', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + // Optional fields should not be present + expect('tooltips' in formData).toBe(false) + expect('placeholder' in formData).toBe(false) + expect('unit' in formData).toBe(false) + expect('default' in formData).toBe(false) + }) + + it('should include optional fields when explicitly set to empty string', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBe('') + expect(formData.tooltips).toBe('') + expect(formData.placeholder).toBe('') + expect(formData.unit).toBe('') + }) + }) + + describe('Multiple fields workflow', () => { + it('should process multiple fields independently', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fields: InputVar[] = [ + { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'name', + max_length: 48, + default_value: 'Alice', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + { + type: PipelineInputVarType.number, + label: 'Count', + variable: 'count', + max_length: 0, + default_value: '10', + required: false, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'items', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + ] + + const formDataList = fields.map(f => convertToInputFieldFormData(f)) + const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd)) + + expect(restoredFields).toHaveLength(2) + expect(restoredFields[0].variable).toBe('name') + expect(restoredFields[0].default_value).toBe('Alice') + expect(restoredFields[1].variable).toBe('count') + expect(restoredFields[1].default_value).toBe('10') + expect(restoredFields[1].unit).toBe('items') + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts new file mode 100644 index 0000000000..0fc4699aa8 --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts @@ -0,0 +1,199 @@ +/** + * Integration test: Input field editor data conversion flow + * + * Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip + * and schema validation for various input types. + */ +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { PipelineInputVarType } from '@/models/pipeline' + +// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + required: false, + options: [], + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], + }, + MAX_VAR_KEY_LENGTH: 30, + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10, +})) + +// Import real functions (not mocked) +const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', +) + +describe('Input Field Editor Data Flow', () => { + describe('convertToInputFieldFormData', () => { + it('should convert a text input InputVar to FormData', () => { + const inputVar: InputVar = { + type: 'text-input', + label: 'Name', + variable: 'user_name', + max_length: 100, + required: true, + default_value: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.type).toBe('text-input') + expect(formData.label).toBe('Name') + expect(formData.variable).toBe('user_name') + expect(formData.maxLength).toBe(100) + expect(formData.required).toBe(true) + expect(formData.default).toBe('John') + expect(formData.tooltips).toBe('Enter your name') + expect(formData.placeholder).toBe('Type here...') + }) + + it('should handle file input with upload settings', () => { + const inputVar: InputVar = { + type: 'file', + label: 'Document', + variable: 'doc', + required: false, + allowed_file_upload_methods: ['local_file', 'remote_url'], + allowed_file_types: ['document', 'image'], + allowed_file_extensions: ['.pdf', '.jpg'], + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url']) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: ['document', 'image'], + allowedFileExtensions: ['.pdf', '.jpg'], + }) + }) + + it('should use template defaults when no data provided', () => { + const formData = convertToInputFieldFormData(undefined) + + expect(formData.type).toBe('text-input') + expect(formData.maxLength).toBe(48) + expect(formData.required).toBe(false) + }) + + it('should omit undefined/null optional fields', () => { + const inputVar: InputVar = { + type: 'text-input', + label: 'Simple', + variable: 'simple_var', + max_length: 50, + required: false, + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBeUndefined() + expect(formData.tooltips).toBeUndefined() + expect(formData.placeholder).toBeUndefined() + expect(formData.unit).toBeUndefined() + }) + }) + + describe('convertFormDataToINputField', () => { + it('should convert FormData back to InputVar', () => { + const formData = { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'user_name', + maxLength: 100, + required: true, + default: 'John', + tooltips: 'Enter your name', + options: [], + placeholder: 'Type here...', + allowedTypesAndExtensions: { + allowedFileTypes: undefined, + allowedFileExtensions: undefined, + }, + } + + const inputVar = convertFormDataToINputField(formData) + + expect(inputVar.type).toBe('text-input') + expect(inputVar.label).toBe('Name') + expect(inputVar.variable).toBe('user_name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.required).toBe(true) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + }) + }) + + describe('roundtrip conversion', () => { + it('should preserve text input data through roundtrip', () => { + const original: InputVar = { + type: 'text-input', + label: 'Question', + variable: 'question', + max_length: 200, + required: true, + default_value: 'What is AI?', + tooltips: 'Enter your question', + placeholder: 'Ask something...', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.type).toBe(original.type) + expect(restored.label).toBe(original.label) + expect(restored.variable).toBe(original.variable) + expect(restored.max_length).toBe(original.max_length) + expect(restored.required).toBe(original.required) + expect(restored.default_value).toBe(original.default_value) + expect(restored.tooltips).toBe(original.tooltips) + expect(restored.placeholder).toBe(original.placeholder) + }) + + it('should preserve number input data through roundtrip', () => { + const original = { + type: 'number', + label: 'Temperature', + variable: 'temp', + required: false, + default_value: '0.7', + unit: '°C', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.type).toBe('number') + expect(restored.unit).toBe('°C') + expect(restored.default_value).toBe('0.7') + }) + + it('should preserve select options through roundtrip', () => { + const original: InputVar = { + type: 'select', + label: 'Mode', + variable: 'mode', + required: true, + options: ['fast', 'balanced', 'quality'], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.options).toEqual(['fast', 'balanced', 'quality']) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/test-run-flow.test.ts b/web/__tests__/rag-pipeline/test-run-flow.test.ts new file mode 100644 index 0000000000..a2bf557acd --- /dev/null +++ b/web/__tests__/rag-pipeline/test-run-flow.test.ts @@ -0,0 +1,277 @@ +/** + * Integration test: Test run end-to-end flow + * + * Validates the data flow through test-run preparation hooks: + * step navigation, datasource filtering, and data clearing. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mutable holder so mock data can reference BlockEnum after imports +const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record[] })) + +vi.mock('reactflow', () => ({ + useNodes: () => mockNodesHolder.value, +})) + +mockNodesHolder.value = [ + { + id: 'ds-1', + data: { + type: BlockEnum.DataSource, + title: 'Local Files', + datasource_type: 'upload_file', + datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} }, + }, + }, + { + id: 'ds-2', + data: { + type: BlockEnum.DataSource, + title: 'Web Crawl', + datasource_type: 'website_crawl', + datasource_configurations: { datasource_label: 'Crawl' }, + }, + }, + { + id: 'kb-1', + data: { + type: BlockEnum.KnowledgeBase, + title: 'Knowledge Base', + }, + }, +] + +// Mock the Zustand store used by the hooks +const mockSetDocumentsData = vi.fn() +const mockSetSearchValue = vi.fn() +const mockSetSelectedPagesId = vi.fn() +const mockSetOnlineDocuments = vi.fn() +const mockSetCurrentDocument = vi.fn() +const mockSetStep = vi.fn() +const mockSetCrawlResult = vi.fn() +const mockSetWebsitePages = vi.fn() +const mockSetPreviewIndex = vi.fn() +const mockSetCurrentWebsite = vi.fn() +const mockSetOnlineDriveFileList = vi.fn() +const mockSetBucket = vi.fn() +const mockSetPrefix = vi.fn() +const mockSetKeywords = vi.fn() +const mockSetSelectedFileIds = vi.fn() + +vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({ + useDataSourceStore: () => ({ + getState: () => ({ + setDocumentsData: mockSetDocumentsData, + setSearchValue: mockSetSearchValue, + setSelectedPagesId: mockSetSelectedPagesId, + setOnlineDocuments: mockSetOnlineDocuments, + setCurrentDocument: mockSetCurrentDocument, + setStep: mockSetStep, + setCrawlResult: mockSetCrawlResult, + setWebsitePages: mockSetWebsitePages, + setPreviewIndex: mockSetPreviewIndex, + setCurrentWebsite: mockSetCurrentWebsite, + setOnlineDriveFileList: mockSetOnlineDriveFileList, + setBucket: mockSetBucket, + setPrefix: mockSetPrefix, + setKeywords: mockSetKeywords, + setSelectedFileIds: mockSetSelectedFileIds, + }), + }), +})) + +vi.mock('@/models/datasets', () => ({ + CrawlStep: { + init: 'init', + }, +})) + +describe('Test Run Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step Navigation', () => { + it('should start at step 1 and navigate forward', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.currentStep).toBe(1) + + act(() => { + result.current.handleNextStep() + }) + + expect(result.current.currentStep).toBe(2) + }) + + it('should navigate back from step 2 to step 1', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('should provide labeled steps', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.steps).toHaveLength(2) + expect(result.current.steps[0].value).toBe('dataSource') + expect(result.current.steps[1].value).toBe('documentProcessing') + }) + }) + + describe('Datasource Options', () => { + it('should filter nodes to only DataSource type', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + // Should only include DataSource nodes, not KnowledgeBase + expect(result.current).toHaveLength(2) + expect(result.current[0].value).toBe('ds-1') + expect(result.current[1].value).toBe('ds-2') + }) + + it('should include node data in options', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current[0].label).toBe('Local Files') + expect(result.current[0].data.type).toBe(BlockEnum.DataSource) + }) + }) + + describe('Data Clearing Flow', () => { + it('should clear online document data', async () => { + const { useOnlineDocument } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDocument()) + + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetSearchValue).toHaveBeenCalledWith('') + expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set)) + expect(mockSetOnlineDocuments).toHaveBeenCalledWith([]) + expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined) + }) + + it('should clear website crawl data', async () => { + const { useWebsiteCrawl } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useWebsiteCrawl()) + + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined) + expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined) + expect(mockSetWebsitePages).toHaveBeenCalledWith([]) + expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1) + }) + + it('should clear online drive data', async () => { + const { useOnlineDrive } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDrive()) + + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockSetBucket).toHaveBeenCalledWith('') + expect(mockSetPrefix).toHaveBeenCalledWith([]) + expect(mockSetKeywords).toHaveBeenCalledWith('') + expect(mockSetSelectedFileIds).toHaveBeenCalledWith([]) + }) + }) + + describe('Full Flow Simulation', () => { + it('should support complete step navigation cycle', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + // Start at step 1 + expect(result.current.currentStep).toBe(1) + + // Move to step 2 + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + // Go back to step 1 + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + + // Move forward again + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('should not regress when clearing all data sources in sequence', async () => { + const { + useOnlineDocument, + useWebsiteCrawl, + useOnlineDrive, + } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result: docResult } = renderHook(() => useOnlineDocument()) + const { result: crawlResult } = renderHook(() => useWebsiteCrawl()) + const { result: driveResult } = renderHook(() => useOnlineDrive()) + + // Clear all data sources + act(() => { + docResult.current.clearOnlineDocumentData() + crawlResult.current.clearWebsiteCrawlData() + driveResult.current.clearOnlineDriveData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/web/__tests__/share/text-generation-run-batch-flow.test.tsx b/web/__tests__/share/text-generation-run-batch-flow.test.tsx new file mode 100644 index 0000000000..a511527e16 --- /dev/null +++ b/web/__tests__/share/text-generation-run-batch-flow.test.tsx @@ -0,0 +1,121 @@ +/** + * Integration test: RunBatch CSV upload → Run flow + * + * Tests the complete user journey: + * Upload CSV → parse → enable run → click run → results finish → run again + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import RunBatch from '@/app/components/share/text-generation/run-batch' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +// Capture the onParsed callback from CSVReader to simulate CSV uploads +let capturedOnParsed: ((data: string[][]) => void) | undefined + +vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({ + default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => { + capturedOnParsed = onParsed + return
CSV Reader
+ }, +})) + +vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({ + default: ({ vars }: { vars: { name: string }[] }) => ( +
+ {vars.map(v => v.name).join(', ')} +
+ ), +})) + +describe('RunBatch – integration flow', () => { + const vars = [{ name: 'prompt' }, { name: 'context' }] + + beforeEach(() => { + capturedOnParsed = undefined + vi.clearAllMocks() + }) + + it('full lifecycle: upload CSV → run → finish → run again', async () => { + const onSend = vi.fn() + + const { rerender } = render( + , + ) + + // Phase 1 – verify child components rendered + expect(screen.getByTestId('csv-reader')).toBeInTheDocument() + expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context') + + // Run button should be disabled before CSV is parsed + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + // Phase 2 – simulate CSV upload + const csvData = [ + ['prompt', 'context'], + ['Hello', 'World'], + ['Goodbye', 'Moon'], + ] + await act(async () => { + capturedOnParsed?.(csvData) + }) + + // Run button should now be enabled + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + // Phase 3 – click run + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledTimes(1) + expect(onSend).toHaveBeenCalledWith(csvData) + + // Phase 4 – simulate results still running + rerender() + expect(runButton).toBeDisabled() + + // Phase 5 – results finish → can run again + rerender() + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + onSend.mockClear() + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledTimes(1) + }) + + it('should remain disabled when CSV not uploaded even if all finished', () => { + const onSend = vi.fn() + render() + + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + fireEvent.click(runButton) + expect(onSend).not.toHaveBeenCalled() + }) + + it('should show spinner icon when results are still running', async () => { + const onSend = vi.fn() + const { container } = render( + , + ) + + // Upload CSV first + await act(async () => { + capturedOnParsed?.([['data']]) + }) + + // Button disabled + spinning icon + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('animate-spin') + }) +}) diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx new file mode 100644 index 0000000000..2a5d1b882c --- /dev/null +++ b/web/__tests__/share/text-generation-run-once-flow.test.tsx @@ -0,0 +1,218 @@ +/** + * Integration test: RunOnce form lifecycle + * + * Tests the complete user journey: + * Init defaults → edit fields → submit → running state → stop + */ +import type { InputValueTypes } from '@/app/components/share/text-generation/types' +import type { PromptConfig, PromptVariable } from '@/models/debug' +import type { SiteInfo } from '@/models/share' +import type { VisionSettings } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { useRef, useState } from 'react' +import RunOnce from '@/app/components/share/text-generation/run-once' +import { Resolution, TransferMethod } from '@/types/app' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => ( +